Task Breakdown: Model accounts, priority chains and fallbacks
Ordered, granular tasks derived from
plan.md. Each task is small enough to land in a single PR and ships with its tests (Constitution Principle VI).
Epic ID: AW-16-models-tokens
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-06
How to use
- Tasks are sequential by default. Tasks marked
(parallel)may run alongside the task above. - Every task names the exact files to create or modify. An implementer should never have to guess a path; if a path is missing here, that is a bug in this file, not a licence to invent.
- Every task states what done means. A task is not done until its stated tests pass.
- Phases P1 / P2 / P3 are each independently shippable and each must leave
developgreen. - Add new tasks at the bottom rather than renumbering.
- Repo root for every path below:
C:/Coding/Worktrees/wt-agent-workspace-specs(ever-works/ever-works).
Phase P1 — Accounts, the ladder, and what actually ran
Goal: more than one credential per provider, a three-level model ladder, and a Run that says what answered it. No failover yet. A workspace that configures nothing behaves exactly as it does today.
P1.a — Contracts and data model
-
T1. Shared contracts for model routing.
- Create
packages/contracts/src/api/model-routing/model-routing.types.tswithReasoningEffort,ModelPolicyScopeType,ModelChainEntry,ModelAccountHealth,ModelAccountView,ModelPolicyView,ResolvedModelPolicy,AgentRunModelRouting(shapes in plan §3.2 / §3.4 / §3.7). - Create
packages/contracts/src/api/model-routing/index.ts; export it frompackages/contracts/src/api/index.ts. ModelAccountViewmust not declare a credential field of any kind.- Done:
pnpm --filter @ever-works/contracts buildemits declarations; a type-level spec atpackages/contracts/src/api/model-routing/model-routing.types.spec.tsassertsModelAccountViewhas nocredentialskey viaexpectTypeOf.
- Create
-
T2.
ModelAccountentity.- Create
packages/agent/src/entities/model-account.entity.tsper plan §3.1, usingEncryptedJsonColumnfrompackages/agent/src/entities/_secret-json-column.tsforcredentialsandPortableDateColumnfrompackages/agent/src/entities/_typesfor every date. - No
@ManyToOnerelations (the EW-654 no-cycle rule every tenant-scoped entity follows). - Export from
packages/agent/src/entities/index.ts. - Done:
cd packages/agent && npx jest --testPathPattern='entities'green; the entity compiles undertsc -p tsconfig.types.json.
- Create
-
T3 (parallel with T2).
ModelPolicyentity.- Create
packages/agent/src/entities/model-policy.entity.tsper plan §3.2. Every routing field nullable so per-field inheritance works. - Export from
packages/agent/src/entities/index.ts. - Done: as T2.
- Create
-
T4. Migration — model accounts and policies.
- Create
apps/api/src/migrations/1791160000000-CreateModelAccountsAndPolicies.ts. CREATE TABLE model_accounts+CREATE TABLE model_policieswith the unique indexes in plan §3.1 / §3.2 and the FKmodel_accounts.userId → users(id) ON DELETE CASCADE.- Portable DDL (
Table/TableColumn/TableIndexobjects, not raw SQL) and every step guarded on the current shape, copyingapps/api/src/migrations/1789000000000-AddFleetCredentialRotation.ts. Fulldown(). - Additive only: no
DROP COLUMN, no rename, no backfill. - Done:
apps/api/src/migrations/__tests__/CreateModelAccountsAndPolicies.spec.tsproves apply-on-empty, apply-twice-is-a-no-op, and revert. Ships in the same PR as T2 and T3 (Constitution V).
- Create
-
T5. Migration — routing record on runs. - Create
apps/api/src/migrations/1791160100000-AddAgentRunModelRouting.tsadding a nullablemodelRoutingjson column toagent_runs. No default, no backfill. - Add the matching@Column({ type: 'simple-json', nullable: true }) modelRouting?: AgentRunModelRouting | null;topackages/agent/src/entities/agent-run.entity.ts, beside the existingcostCents/totalTokensblock. - Done:apps/api/src/migrations/__tests__/AddAgentRunModelRouting.spec.tsgreen; an existing run row still reads back withmodelRouting === null. -
T6. Activity-log action types.
- Append the nine members in plan §3.5 to
ActivityActionTypeinpackages/agent/src/entities/activity-log.types.ts. Append only — reorder nothing. - Done: no migration is required (the column is
varchar(50)); a unit assertion inpackages/agent/src/entities/__tests__/activity-log.types.spec.ts(create if absent) proves every member is ≤ 50 characters.
- Append the nine members in plan §3.5 to
P1.b — Domain services
-
T7.
ModelAccountService.- Create
packages/agent/src/model-routing/model-account.service.tswithlist,create,update,replaceCredentials,reorder,pause,resume,remove,markUsed. - Enforce inside one transaction: 8 per provider, 32 per workspace, unique label,
contiguous 1..N positions with renumbering on delete, and optimistic
expectedVersionon reorder. - Every mutation writes one activity-log entry naming the field, never the value.
- Create
packages/agent/src/model-routing/model-routing.module.tsand register it where the other agent-package feature modules are registered. - Done:
packages/agent/src/services/__tests__/model-account.service.spec.tscovers both limits, renumbering, duplicate labels, the reorder conflict, and pause/resume preserving position (spec FR-1..12).
- Create
-
T8.
ModelPolicyService+ModelPolicyResolver. - Createpackages/agent/src/model-routing/model-policy.service.ts(CRUD per scope) andpackages/agent/src/model-routing/model-policy.resolver.ts(resolve({ userId, organizationId, agentId?, scheduleOwnerId?, scheduleVariant? })→ResolvedModelPolicywith asourceper field). - Resolution is per field, narrowest wins: schedule → agent → workspace → plugin default. A Schedule setting only the model still inherits effort and timeout. - Legacy read: when nomodel_policiesrow exists for an Agent that hasaiProviderId/modelIdset (packages/agent/src/entities/agent.entity.ts:273-277), read those two columns as a policy of one. - Done:packages/agent/src/facades/__tests__/model-policy-resolver.spec.tscovers every ladder combination, the legacy path, and per-field independence (FR-25..36). -
T9.
ModelAttemptPlanner— single-attempt form.- Create
packages/agent/src/facades/model-attempt-planner.ts. In P1 it returns exactly one attempt: the resolved primary on that provider's highest-position usable account, or — when there is no account and no policy — the plugin's own resolved settings, i.e. today's behaviour byte for byte. - Merge account credentials over
getResolvedSettings()output using the plugin's own setting keys; never translate key names. - Done:
packages/agent/src/facades/__tests__/model-attempt-planner.spec.tsproves the empty-configuration case is identical to the pre-epic path.
- Create
-
T10.
ModelAccountHealthService.- Create
packages/agent/src/model-routing/model-account-health.service.ts:probe(accountId),probeDueAccounts(),applyLiveFailure(accountId, class). - Thresholds:
expiringat 14 days, banner at 3 days; a probe failure setsunknown, a provider rejection setsinvalid; never changesposition. - Prefer the plugin's
checkCredentialwhen present (T11), elselistModels, else leaveunknown(spec FR-62, FR-69). - Done:
packages/agent/src/services/__tests__/model-account-health.service.spec.tscovers both thresholds and the failure-vs-rejection distinction (FR-60..69).
- Create
-
T11. Optional plugin-contract additions.
- Add optional
reasoningSupport?andcheckCredential?toIAiProviderPlugininpackages/plugin/src/contracts/capabilities/ai-provider.interface.ts. - Add optional
reasoningEffort?,attemptTimeoutMs?,scheduleId?toAiRoutingOptionsinpackages/plugin/src/facades/ai-facade.interface.ts. - Add optional
scheduleId?andpolicyOverride?toFacadeOptionsinpackages/plugin/src/facades/facade-options.interface.ts. - All optional; do not touch
packages/plugin/src/ai/reasoning.utils.ts— it stays as the fallback when a plugin declares noreasoningSupport. - Bump
@ever-works/pluginas a minor version (Constitution X). - Done:
cd packages/plugin && pnpm testgreen;pnpm buildacross the monorepo compiles with no call site changed.
- Add optional
P1.c — Facade wiring and the routing record
-
T12. Wire the planner into the facade.
- Modify
packages/agent/src/facades/ai.facade.ts: inaskJson,createChatCompletionandcreateStreamingChatCompletion, callModelAttemptPlanner.plan(facadeOptions)aftergetResolvedSettings()and beforeenforceBudget. KeepenforceBudgetexactly where it is and keepwithEscalationas the P1 retry. - Do not change
resolveModel,embed,transcribe,testConnectionorgetAvailableModels. - Done:
packages/agent/src/facades/__tests__/ai.facade.spec.tsextended to prove the no-policy path is unchanged and that a budget block still throws before any attempt.
- Modify
-
T13. Record what actually ran.
- In the same three methods, build an
AgentRunModelRoutingand persist it ontoagent_runs.modelRoutingwhenfacadeOptions.runIdis set. Record nothing when no model call was made (spec FR-84). - Done:
packages/agent/src/facades/__tests__/model-attempt-planner.redaction.spec.tsasserts the serialised record contains none of the account's credential values as a substring (FR-83);ai.facade.spec.tsasserts provider/model/accountLabel/effort are present.
- In the same three methods, build an
-
T14. Health probe task.
- Create
packages/tasks/src/tasks/trigger/model-account-health.task.tsas aschedules.task({ id: 'model-account-health', cron: '19 */6 * * *' }), reachingModelAccountHealthServicethroughwithWorkerContextexactly aspackages/tasks/src/tasks/trigger/agent-run-sweeper.task.tsdoes. - Claim each row with an atomic
UPDATE … WHERE lastCheckedAt < :cutoffso overlapping ticks cannot double-probe. - Export from
packages/tasks/src/tasks/trigger/index.ts. - Done: a unit test for the claim predicate; a manual
pnpm dev:triggerfire logs one probe per account and zero on the immediate re-fire.
- Create
P1.d — API
-
T15. Model accounts controller + DTOs.
- Create
apps/api/src/model-routing/model-routing.module.ts,apps/api/src/model-routing/model-accounts.controller.ts, andapps/api/src/model-routing/dto/{create-model-account.dto.ts,update-model-account.dto.ts,replace-credentials.dto.ts,reorder-model-accounts.dto.ts}. - Routes exactly as plan §4.1, including
GET /api/model-accounts/providersbuilt from the plugin registry by capability — no literal provider list anywhere. - Add an explicit
toModelAccountViewmapper inapps/api/src/model-routing/model-account.mapper.ts. Never spread the entity. - Register the module in
apps/api/src/api.module.ts. - Swagger decorators (
@ApiOperation,@ApiResponse) on every route. - Done:
apps/api/src/model-routing/__tests__/model-accounts.controller.spec.tsproves the auth matrix, both 409 shapes, the 422 on a rejected credential, and — as its own test — that no response body from any route contains the credential the test wrote (FR-87).
- Create
-
T16. Model policies controller + DTOs.
- Create
apps/api/src/model-routing/model-policies.controller.tsandapps/api/src/model-routing/dto/upsert-model-policy.dto.ts. - Custom validators in
apps/api/src/model-routing/dto/validators/:not-primary-in-fallbacks.validator.ts,unique-chain-entries.validator.ts. - Bounds per plan §4.2 (
@ArrayMaxSize(3),@Min(60) @Max(7200),@Min(15) @Max(600),@IsIn(['minimal','low','medium','high'])). - Implement
GET /api/model-policies/resolvedreturning per-fieldsource. - Done:
apps/api/src/model-routing/__tests__/model-policies.controller.spec.tsrejects a 4-entry chain, a chain containing the primary, a duplicate entry, a 30-second timeout, a 3-hour timeout and an unknown effort value.
- Create
P1.e — Web
-
T17. Settings → Models route and shell.
- Create
apps/web/src/app/[locale]/(dashboard)/settings/models/page.tsx(RSC) fetching accounts, workspace policy and providers withPromise.allSettledso one failure degrades to its own panel. - Create
apps/web/src/components/settings/ModelSettings.tsx,ModelAccountsPanel.tsx,ModelAccountRow.tsx,AddModelAccountDialog.tsx,ModelDefaultsPanel.tsx,ModelPickerField.tsx(all underapps/web/src/components/settings/). - Add the tab to
apps/web/src/app/[locale]/(dashboard)/settings/settings-layout-client.tsx, afterconnectionsand beforejob-runtime. - Credential fields in the add dialog are rendered from the plugin's declared settings
schema, reusing the
x-secrethandling already used byapps/web/src/components/plugins/form/PluginModelSelect.tsx's sibling widgets. - Wireframes and every literal string:
spec.md§6.1–§6.3, §6.9. - Done:
apps/web/e2e/settings-model-accounts.spec.tscovers add, reorder in three interactions, pause/resume, remove-with-consequence, both over-limit states, the read-only state and the load-error panel.
- Create
-
T18. Agent model panel.
- Create
apps/web/src/components/agents/AgentModelPanel.tsxand mount it inapps/web/src/app/[locale]/(dashboard)/agents/[id]/settings/page.tsx, replacing that page's provider/model pair. - The new panel does not call
listByCategory('ai-gateway')— that category does not exist inpackages/plugin/src/contracts/plugin-manifest.types.tsand the call always resolved to[]. Remove only that call from the page; change no shared API. - Copy:
spec.md§6.5. - Done: the Agent settings page renders with no network call to a non-existent
category;
apps/web/e2e/model-override-ladder.spec.ts(T20) covers the panel.
- Create
-
T19. Schedule model drawer.
- Create
apps/web/src/components/schedules/ScheduleModelDrawer.tsx, exported standalone and taking{ ownerId, variant, agentName }so the surface that opens it (AW-10) can mount it without this epic owning that page. - Copy:
spec.md§6.6. - Done: a unit spec
ScheduleModelDrawer.unit.spec.tsxrenders both radio states and asserts the inherited value is named.
- Create
-
T20. API clients.
- Create
apps/web/src/lib/api/model-accounts.ts,model-policies.tsfollowing the existingapps/web/src/lib/api/*.tsshape and routing throughapps/web/src/lib/api/bff-proxy.tswith the active-scope header. - Done:
apps/web/src/lib/api/model-accounts.unit.spec.tsasserts the scope header is forwarded on every call (the BFF selector-forwarding rule).
- Create
-
T21. Health banner.
- Create
apps/web/src/components/dashboard/ModelAccountHealthBanner.tsx, modelled onapps/web/src/components/dashboard/JobRuntimeDegradedBanner.tsx: hydration-gated,nullrenders nothing,localStoragedismissal keyed by the set of unhealthy account ids so it returns when that set changes. - Mount in
apps/web/src/app/[locale]/(dashboard)/layout-client.tsximmediately after<JobRuntimeDegradedBanner />. - Copy:
spec.md§6.7. - Done:
apps/web/e2e/model-account-health-banner.spec.tscovers the four variants and dismissal persistence.
- Create
P1.f — i18n, docs, and the P1 gate
-
T22. i18n keys (P1 subset).
- Add to
apps/web/messages/en.json: the wholedashboard.settings.modelsnamespace,dashboard.agentModel,dashboard.scheduleModel, the expiry half ofdashboard.modelBanners,dashboard.runReceipt.routing,dashboard.settings.tabs.modelsandmetadata.pages.settingsModels(full list:plan.md§8). - Leaf key names are camelCase and contain no literal
.— a literal dot fails next-intl at runtime and reds several e2e shards at once. - Mirror the same key set into all 20 sibling locale files in
apps/web/messages/. - Done:
pnpm --filter web testgreen including the i18n key-coverage spec; no console error inapps/web/e2e/settings-model-accounts.spec.ts.
- Add to
-
T23. Program vocabulary table.
- Add Model Account and Model Policy to the vocabulary table in
docs/specs/features/agent-workspace/README.md§1, with the "do not introduce" column naming the synonyms to avoid ("token", "key" as an entity, "profile"). - Program rule 2 requires this in the same PR that lands the new nouns.
- Done: the table lists both nouns and links to this epic.
- Add Model Account and Model Policy to the vocabulary table in
-
T24. P1 gate.
- Run
pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm build. - Confirm
apps/web/e2e/flow-plugin-ai-models-catalogue.spec.tsis untouched and green — the live-probed plugin/models contract must not move. - Confirm a workspace with zero
model_accountsand zeromodel_policiesrows produces byte-identical model resolution to the pre-epic build. - Done: CI green on
develop.
- Run
Phase P2 — Failover
Goal: chains that actually move. An empty fallback list must produce exactly the P1 attempt list, so P2 is a no-op for anyone who does not configure it.
-
T25. Failure classifier. - Create
packages/agent/src/facades/model-failure-classifier.ts— a pure function mapping a provider error torate_limited | credential | transient | context_too_large | fatal, perplan.md§2.2. - Done:packages/agent/src/facades/__tests__/model-failure-classifier.spec.tscovers every row, including that 400/404/422 isfataland that aRetry-Afterheader is parsed into a cooldown. -
T26. Cooldown state.
- Extend
ModelAccountServicewithapplyCooldown(accountId, class, retryAfterMs?)andclearElapsedCooldowns(), writingcooldownReason,cooldownUntil,consecutiveFailuresonmodel_accounts. - Arithmetic: credential 15 min; rate limit = stated delay else 60 s, capped 30 min; transient 60 s, 5 min after 3 within a 5-minute window.
- Done: covered in
model-account-health.service.spec.tsincluding the 30-minute cap (FR-20..22).
- Extend
-
T27. Cooldown sweeper task.
- Create
packages/tasks/src/tasks/trigger/model-account-cooldown-sweeper.task.ts(cron: '*/5 * * * *'), exported frompackages/tasks/src/tasks/trigger/index.ts. - Done: a unit test proves the sweep is a pure
WHERE cooldownUntil < now()update and is idempotent.
- Create
-
T28. Full attempt planner.
- Extend
packages/agent/src/facades/model-attempt-planner.tsto build the full product: chain entries outer (primary then fallbacks in order), that entry's provider's accounts inner (ascendingposition), skipping paused and cooling-down accounts, never repeating a(model, account)pair, truncating at 6 attempts and flagging that it did. - A chain entry whose provider has no usable account is skipped without consuming an attempt.
- Done:
model-attempt-planner.spec.tsextended to cover FR-15..24 and FR-42, plus the property that an empty fallback list yields exactly the P1 list.
- Extend
-
T29. Attempt execution in the facade.
- Replace
withEscalationusage inaskJson,createChatCompletionandcreateStreamingChatCompletionwithrunAttempts(list)inpackages/agent/src/facades/ai.facade.ts. KeepwithEscalationin the file and keep emitting it as attempt 2 when acomplexityis set and no fallback chain exists. enforceBudgetstill runs once, before attempt 1, and its exception propagates untouched (FR-46).- Streaming: resolve the chain before the first byte; do not fail over mid-stream.
- Done:
ai.facade.spec.tscovers rate-limit → next account, credential → next account then next model,fatal→ immediate failure with no walk, budget block → no attempt at all.
- Replace
-
T30. Context-window advance.
- In the planner, on
context_too_large, advance to the next chain entry whose known context window (fromAiFacadeService.resolveModelContextLength, backed bypackages/agent/src/facades/model-catalog.ts) is larger; otherwise fail immediately. - Done: covered in
model-failure-classifier.spec.tsandmodel-attempt-planner.spec.ts(FR-45).
- In the planner, on
-
T31. Reasoning effort application.
- In
packages/agent/src/facades/ai.facade.ts, thread the resolved effort into the plugin call as the provider's own control whenIAiProviderPlugin.reasoningSupport?.(modelId)(T11) reports a level; map to the nearest supported level; when the model exposes none, recordeffort: 'not-applicable'and change nothing else. mediumon a workspace that never set an effort must be behaviour-neutral — the existing automatic per-model rule inpackages/plugin/src/ai/reasoning.utils.tsstill applies unchanged (FR-52).- Done:
ai.facade.spec.tsproves the neutrality property and the nearest-level mapping.
- In
-
T32. Run timeout as a hard stop.
- Enforce the resolved
runTimeoutSecondsinpackages/agent/src/agents/agent-run.service.ts: a Run past its deadline ends asfailedwitherrorMessage"Run timed out after {n} minutes" andmodelRouting.outcome = 'timeout'carrying the in-flight attempt. AgentRunStatusis not extended — the union staysqueued | running | completed | failed | cancelled(Constitution X).- Done: a unit spec in
packages/agent/src/agents/__tests__/proves the transition and that the routing record survives it (FR-55, FR-56).
- Enforce the resolved
-
T33. Per-attempt deadline.
- Apply the resolved
attemptTimeoutSeconds(default 120, range 15–600) as anAbortSignalon each attempt, clamped so the sum can never exceed the remaining run budget. A breached attempt deadline classifies astransient, not as a Run failure. - Done:
model-attempt-planner.spec.tsproves the clamp;ai.facade.spec.tsproves the classification (FR-57, FR-58).
- Apply the resolved
-
T34. Chain builder UI.
- Create
apps/web/src/components/settings/ModelChainBuilder.tsxand mount it inModelDefaultsPanel.tsx,AgentModelPanel.tsxandScheduleModelDrawer.tsx. - Owns FR-39/40/41 in the UI: the picker's option list is built by removing the current primary and every entry already in the chain; changing the primary to an entry already in the chain removes it and shows the inline note for 6 seconds; the chain-too-long note appears on save.
- Roving tabindex,
Alt+↑/Alt+↓reorder,Backspaceremoves, politearia-liveannouncements (spec.md§6.10). - Done:
apps/web/e2e/settings-model-defaults.spec.tscovers the picker exclusion, the auto-removal note, the chain-too-long note, the effort radios, and the timeout bounds and warning.
- Create
-
T35. Timeout-too-short warning.
- Server-side: extend
GET /api/model-policies/resolved(or addGET /api/model-policies/timeout-impact) to return how many Schedules have a recent typical duration above a proposed timeout. Warn, never block (FR-59). - Done: covered in
model-policies.controller.spec.tsand insettings-model-defaults.spec.ts.
- Server-side: extend
-
T36. i18n keys (P2 subset).
- Add the
defaults.*,effort.*andtimeout.*leaves ofdashboard.settings.models, plus the failover strings ofdashboard.runReceipt.routing, toapps/web/messages/en.jsonand the 20 sibling locales. camelCase leaves, no literal dots. - Done: i18n key-coverage spec green.
- Add the
-
T37. P2 gate.
pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm build.- Confirm a workspace with a primary and no fallbacks produces exactly the P1 attempt list.
- Done: CI green on
develop.
Phase P3 — Credentials on computers
Goal: the accounts reach the machines agents control, and the owner can see when they have not. A workspace with no enrolled computers must see nothing new at all.
-
T38. Bundle entity + migration.
- Create
packages/agent/src/entities/model-credential-bundle.entity.ts(plan §3.3) and export it frompackages/agent/src/entities/index.ts. - Add
appliedModelBundleVersion(int NULL) andmodelBundleRequestedAt(timestamp NULL) topackages/agent/src/entities/fleet-node.entity.ts. - Create
apps/api/src/migrations/1791160200000-CreateModelCredentialBundle.tsdoing both, portable DDL, guarded, with a fulldown(). - Done:
apps/api/src/migrations/__tests__/CreateModelCredentialBundle.spec.tsproves apply / re-apply / revert and that no pre-existingfleet_nodescolumn is touched.
- Create
-
T39.
ModelBundleService.- Create
packages/agent/src/model-routing/model-bundle.service.ts:bumpVersion(scope),buildBundle(scope)(canonicalised body + sha256contentHash),status(scope)(version, out-of-sync count, unreachable nodes at the 10-minute threshold),requestSend(scope). - Call
bumpVersionfromModelAccountServiceandModelPolicyServiceon every write. - Done:
packages/agent/src/services/__tests__/model-bundle.service.spec.tscovers monotonicity, hash stability under key reordering, and the counting rules (FR-70..76).
- Create
-
T40. Fan-out dispatcher symbol.
- Create
packages/agent/src/tasks/model-bundle-fanout-dispatcher.tsexportingMODEL_BUNDLE_FANOUT_DISPATCHER = Symbol.for('MODEL_BUNDLE_FANOUT_DISPATCHER')and its producer-side interface, copyingpackages/agent/src/tasks/webhook-delivery-dispatcher.ts. - Re-export from
packages/agent/src/tasks/index.ts. - Add the name to
TASKS_BARREL_RUNTIME_SYMBOLSinpackages/agent/src/tasks/_tasks-symbols.ts(alphabetical insertion) — omitting this fails CI one merge late, as that file's own header records. - Bind it in
packages/agent/src/tasks/job-runtime.providers.tsalongside the other dispatchers (Constitution IV — never a direct queue call). - Done:
packages/agent/src/tasks/tasks.spec.ts(the barrel-symbol pin) passes without modification beyond the one-line list entry.
- Create
-
T41. Fan-out task.
- Create
packages/tasks/src/tasks/trigger/model-bundle-fanout.task.tsstampingmodelBundleRequestedAton every enrolled node of a workspace; keyed on(scope, version)so a re-run is a no-op. Export from that directory'sindex.ts. - Done: unit test proves the no-op on re-run.
- Create
-
T42. Node-authenticated bundle fetch.
- Add
POST fleet/model-bundletoapps/api/src/fleet/fleet.controller.tsbesideheartbeat/rotate-credential:@Public(), node-secret authenticated,@Throttled well below the heartbeat allowance, one undifferentiated failure message, body never logged, response not cacheable. - Add
modelBundleVersion?: numbertoFleetHeartbeatDtoandmodelBundleRequested?: booleantoFleetHeartbeatResponseinpackages/contracts/src/fleet/fleet-node.types.ts— both optional; an absent value means "leave alone", matching the existing additive-telemetry contract used bycliVersion. - Done:
apps/api/src/fleet/__tests__/fleet-model-bundle.controller.spec.tsproves the bad-secret path is indistinguishable from the heartbeat's, that a valid fetch returns material, and that omittingmodelBundleVersionleaves the column untouched.
- Add
-
T43. Node applies and reports.
- Create
apps/node/src/core/model-bundle.ts: fetch onmodelBundleRequested, write each value throughapps/node/src/core/secret-store.ts(OS keychain first), skip whencontentHashis unchanged, report the applied version on the next heartbeat viaapps/node/src/core/heartbeat.ts. - Expose the applied names to the model CLI executor's
envPassthroughallow-list inapps/node/src/core/executors/model-cli.ts— names only; values continue to be read from the process environment and scrubbed from reported output. - Done:
apps/node/src/core/model-bundle-apply.spec.tsproves keychain-first storage, no value in any log line, the unchanged-hash no-op, and the report-on-next-beat.
- Create
-
T44. Bundle status API. - Create
apps/api/src/model-routing/model-bundle.controller.tswithGET /api/model-bundle/status(member) andPOST /api/model-bundle/send(admin,202). - Done:apps/api/src/model-routing/__tests__/model-bundle.controller.spec.tscovers the counts and the auth split. -
T45. Sync banner.
- Create
apps/web/src/components/settings/ModelBundleSyncBanner.tsx; mount it inapps/web/src/app/[locale]/(dashboard)/settings/models/page.tsxand inapps/web/src/app/[locale]/(dashboard)/layout-client.tsx. - Four states plus "not rendered at all" when there are zero enrolled computers
(
spec.md§6.7, FR-78). It clears itself; no user action is required to dismiss a resolved state. - Create
apps/web/src/lib/api/model-bundle.ts. - Done:
apps/web/e2e/model-bundle-sync.spec.tscovers pending, sending, unreachable, cleared, and the zero-computers case.
- Create
-
T46. i18n keys (P3 subset).
- Add the
sync*leaves ofdashboard.modelBannerstoapps/web/messages/en.jsonand the 20 sibling locales. camelCase leaves, no literal dots. - Done: i18n key-coverage spec green.
- Add the
-
T47. P3 gate.
pnpm format && pnpm lint && pnpm type-check && pnpm test && pnpm build.- Confirm a workspace with no enrolled computers renders no sync banner anywhere.
- Confirm a node running the previous daemon version still heartbeats successfully and is simply not counted.
- Done: CI green on
develop.
Cross-cutting — do these once, at the end of the phase that first needs them
-
T48. Telemetry.
- Emit
model_attempt_total,model_fallback_depth,model_call_duration_ms,model_account_health,model_bundle_lag_secondsthroughpackages/monitoring/; add the Sentry tagsmodel.provider,model.account(label, not id) andmodel.attempt. - Done: a redaction spec asserts no credential value reaches a Sentry tag, breadcrumb, message or extra (Constitution VII).
- Emit
-
T49. Correct the drifted architecture doc.
- Update
docs/specs/architecture/ai-facade.mdso §2 (method names), §5 (catalogue source) and §8 (AiRoutingOptionsfields) describe the interface that actually ships after T11 — including that the catalogue is fetched live with a 1-hour in-process TTL and that there is no embedded snapshot and no refresh task. - Done: every claim in that document is checkable against
packages/plugin/src/facades/ai-facade.interface.tsandpackages/agent/src/facades/model-catalog.ts.
- Update
-
T50. User-facing documentation.
- Create
docs/features/model-accounts.mdcovering: adding a second account, what the numbered order means, the three-level model ladder, fallback chains and why the primary is never its own fallback, reasoning effort, the run timeout, expiry warnings and the sync signal. - Cross-link from
docs/features/index.mdand add it toapps/docs/sidebarsPlatform.ts(the sidebar is manual; a file not listed there renders only as an orphan page). - Done:
pnpm --filter ever-works-docs buildproduces no broken-link warnings.
- Create
-
T51. Hand the routing record to the receipt.
-
T52. Hand the drawer to the schedules surface.
- Confirm with AW-10 that
ScheduleModelDraweris mounted from the schedule row and thatModelPolicy.scopeVariantuses the same source vocabulary asdocs/specs/features/schedules/spec.md§1.3. - Done: the two vocabularies match member for member.
- Confirm with AW-10 that
-
T53. Resolve the open questions.
- Take
spec.md§9's seven[NEEDS CLARIFICATION: …]markers to Product and record the answers in the spec before it moves pastDraft. - Done:
grep -c 'NEEDS CLARIFICATION' docs/specs/features/agent-workspace/AW-16-models-tokens/spec.mdreturns 0.
- Take
-
T54. Close out.
Definition of Done
- Every checkbox above is ticked.
pnpm format:check,pnpm lint,pnpm type-check,pnpm testandpnpm buildare green.pnpm --filter ever-works-docs buildproduces no broken-link warnings.- Every functional requirement FR-1 … FR-93 in
spec.md§4 has at least one passing test, and each acceptance-criteria line in §8 has been run by a reviewer. - Every constitution gate in
spec.md§10 and every row ofplan.md§12 is confirmed satisfied. - A workspace that upgrades and configures nothing resolves models exactly as it did before.
- No credential value appears in any API response, log line, trace, metric tag, activity-log entry, export or run record — proven by a test, not by inspection.