Joye Dev

Back

#5821:workspace pool 真正进入“单一付款方”的 credits 运行时

这条业务线解决的不是“在个人套餐里增加一个 Team”。真正的问题是:工作区为席位持有人买了能力之后,成员在这个 workspace 里做的工作应该由谁付款;个人余额、workspace pool、操作发生地和退款对象必须能在同一条数据流里被区分


source_automation: voyager-merged-pr run_date: 2026-08-10 anchor_pr_number: 5821 pr_number: 5821 pr_title: “feat(credits): let a workspace pool pay for work done in it” pr_url: https://github.com/adastralab-ai/voyager/pull/5821 author: “Shawn Hu / huxwfun” merged_at: “2026-08-10T03:09:07Z” modules:

  • backend/go/apps/api/contract_test/credits/e2e/helpers_test.go
  • backend/go/apps/api/contract_test/credits/e2e/pool_pays_test.go
  • backend/go/apps/api/contract_test/credits/e2e/spend_test.go
  • backend/go/internal/agent/service.go
  • backend/go/internal/billing/model.go
  • backend/go/internal/billing/service.go
  • backend/go/internal/brand/reference_task.go
  • backend/go/internal/brand/service_import.go
  • backend/go/internal/brand/service_integration_test.go
  • backend/go/internal/brand/showcase_generate.go
  • backend/go/internal/credits/errors.go
  • backend/go/internal/credits/model.go
  • backend/go/internal/credits/module.go
  • backend/go/internal/credits/repo.go
  • backend/go/internal/credits/service.go
  • backend/go/internal/credits/service_integration_test.go
  • backend/go/internal/image/service.go
  • backend/go/internal/task/service.go
  • backend/go/internal/task/service_integration_test.go
  • backend/workers/agent/src/goApi.ts
  • backend/workers/agent/src/goApi.test.ts
  • packages/site/src/app/(main)/_agent/chat/_components/InsufficientCreditsNotice.tsx files_changed: 22 learning_tags:
  • billing
  • team-seats
  • credit-pool
  • single-payer-debit
  • refund-attribution
  • workspace-scope
  • credit-ledger
  • postgres-locking
  • cross-module-contract
  • rollout-compatibility
  • integration-testing business_line: “Team 席位、workspace credit pool 与 credits ledger 的付款归属和运行时扣费” related_prs: [5817, 5815, 5771, 5820, 5833] line_stage: “个人 Plan 与 seat type 分离 (#5817) -> PlanResolution/Entitlements 边界 (#5815) -> workspace bucket/ledger schema (#5771) -> seat entitlement 并集 (#5820) -> workspace pool 单一付款方 debit/refund (#5821) -> workspace checkout/webhook 购买入口 (#5833)” open_questions:
  • “#5821 的 ResolveFunding 在 task/agent 事务外读取 seat;seat 被撤销后到 DebitTx/DebitAgentTurn 之间没有再次授权校验,余额锁不能替代成员资格校验。”
  • “#5833 的 AssignSeat、RevokeSeat、UpdateSeatCount 仍是显式 not implemented;workspace checkout/webhook 也没有创建 pool 或把购买数量转换成 bucket。#5821 的 E2E 只能直接写 workspace_seats 和 credit_buckets fixture。”
  • “PoolView.AvailableToMe 当前等于 pool 总余额,SeatMonthlyAllotment、每日 holder 上限和 bucket_id/user_id/created_at 用量索引尚未接入扣费决策。”
  • “任务扣费在 pool 不足整笔时整笔回落个人余额;Agent turn 是事后扣费,个人余额为零时允许 pool 用 residue 部分扣到零。两套规则需要产品和账务确认是否长期保持不同。”
  • “#5771 的 owner CHECK/FK 仍是 NOT VALID,旧 personal upsert 的 unique conflict target 仍保留;后续 validation 和旧 upsert 清理没有在本线闭合。”
  • “workspace_id 已写入个人付款和 pool 付款的 task/agent ledger,但当前索引按 bucket、holder、时间服务 pool usage;workspace 审计/报表查询尚无明确索引契约。”
  • “当前测试覆盖真实 Postgres/HTTP 与 Stripe fake,但没有部署级 Stripe checkout -> webhook -> seat assignment -> pool seeding -> debit/refund -> browser gate 链路。” feishu_doc_url: null

业务线概览#

这条业务线解决的不是“在个人套餐里增加一个 Team”。真正的问题是:工作区为席位持有人买了能力之后,成员在这个 workspace 里做的工作应该由谁付款;个人余额、workspace pool、操作发生地和退款对象必须能在同一条数据流里被区分。

当前代码已经把四个问题拆开:

  • Plan 只描述账号自己购买的 free/basic/pro/mega 阶梯;SeatType 描述 workspace 买的能力类型。
  • workspace_subscriptions 是 workspace 级 Stripe 订阅,workspace_seats 是成员获得池子访问权的资格。
  • credit_buckets 的 owner 可以是 user 或 workspace;credit_transactions.bucket_id 记录真正移动余额的桶,workspace_id 记录操作发生地。
  • ResolveFunding(userID, workspaceID) 只回答这一次操作可以由哪个余额付款;它不把 personal plan、workspace pool 和可用额度合成一个模糊的“总余额”。

今日锚点 #5821 是这条线从 schema 进入运行时的关键一步:它接通了 seat -> workspace pool -> task/agent debit -> ledger -> refund,并把 worker 的 Agent turn gate 改成只接受当前 workspace 的 pool。它没有完成购买、分配席位或创建 pool,因此“代码路径存在”不能推导为“线上用户已经能使用 Team credits”。

今日锚点#

  • 标题:feat(credits): let a workspace pool pay for work done in it
  • 编号:#5821
  • 作者:Shawn Hu / huxwfun
  • merge 时间:2026-08-10 03:09:07 UTC(Australia/Melbourne 13:09:07)
  • merge commit:929bad10c614d5e6da7946c961adbafce3219cad
  • 选择理由:它是 2026-08-10 Melbourne 合并窗口里最能推进既有 Team credits 业务线的未学习 PR,直接实现了上一日 #5771 预留的 bucket owner/ledger schema;同时与当天先后的 #5820、#5833 共享 billing、workspace、credits、Stripe contract 和真实集成测试,而不是标题相似的 UI 或权限机械变更。

演进时间线#

阶段PR改变的层代码证据与意义
个人阶梯与席位类型分离#5817,2026-08-10 00:27 MelbourneTypeSpec/Go model/生成契约删除 PlanTeamPlanForPriceKey 不再把 team seat price 映射到个人 Plan,改由 SeatTypeForPriceKey 读取。席位不再伪装成个人订阅阶梯。
计费事实与能力边界收敛#5815,2026-08-10 01:22 Melbournebilling model/creditsPlanResolution 只保留个人 Plan、price key、billing cycle 和 PlanConfirmed,不再把整行 subscription 透传;能力判断集中到 EntitlementsForUser,为后续 seat entitlement 和 funding resolver 留出窄边界。
持久化表达 workspace owner#5771,2026-08-09 17:20 MelbournePostgreSQL migration/indexcredit_buckets.workspace_id 作为第二种 owner,并用 XOR check 保证 user/workspace 二选一;ledger 增加 workspace_id,pool partial unique index 和 paying-bucket usage index 先于任何 pool 写入建立。
席位授予能力#5820,2026-08-10 10:43 Melbournebilling entitlement/runtime个人 entitlements 与仍在 workspace 中的 Basic/Pro seat 按字段求并集;GUEST 不算有效成员,查询失败对能力路径 fail-open 回落个人购买。它解决“能做什么”,不决定“谁付款”。
单一付款方 debit/refund#5821,2026-08-10 13:09 Melbournebilling/credits/task/agent/worker通过 ResolveFunding 按操作 workspace 选择 pool;pool 能覆盖整笔任务才付款,否则整笔由个人余额承担;退款按 debit 行的 bucket 退回,并把 workspace 写入每条 ledger。Agent gate 只看当前 workspace 的 pool。
购买与 webhook 入口#5833,2026-08-10 19:28 MelbourneStripe checkout/webhook/workspace billingworkspace 使用独立 Stripe customer,checkout 在 subscription metadata 写入 workspaceId,webhook 按该 metadata 写 workspace_subscriptions;但 AssignSeat、pool seeding 和 seat count 更新仍未实现。

这条线的层次变化可以概括为:先删除错误的 PlanTeam 抽象,再把“能力”与“付款”分开,之后才让 workspace 成为 bucket owner。#5821 是运行时的付款选择层;#5833 是支付对象和 webhook 的入口层,二者并不意味着完整 Team 业务已经闭合。

当前架构与数据流#

flowchart LR
  A["Workspace checkout"] --> B["workspace_subscriptions + Stripe subscription metadata"]
  B --> C["workspace_seats / active membership"]
  C --> D["ResolveFunding(user, workspace)"]
  D --> E["Task Submit or Agent turn debit"]
  E --> F["Choose one payer: pool or personal"]
  F --> G["credit_buckets balance + credit_transactions"]
  E --> H["Publish after commit"]
  H --> I["Fail -> Refund by debit.bucket_id"]
  D --> J["GET /api/credits/me pools"]
  J --> K["Agent gate matches current workspace"]
plaintext
  1. Workspace billing入口。 #5833 的 WorkspaceCheckout 不复用个人 checkout:它为 workspace 使用独立 Stripe customer,先 UpsertPendingWorkspaceSubscription 写本地行,再创建 Stripe session,并把 workspaceId 写进 subscription_data.metadata。webhook 读取 subscription 本身的 metadata,命中 workspace 分支后用 stale guard 更新 workspace_subscriptions;没有 metadata 的个人事件仍走原路径。
  2. 能力与成员资格。 #5820 的 entitlementsFor 以个人 Plan 为基线,再对 activeSeats 做 entitlement widen。activeSeats 只接受 owner/admin/member,不接受 GUEST;seat 查询或 membership 查询失败时,能力路径回落个人 plan。#5821 把底层读取拆成返回 error 的 seatsForUser,让 funding 路径不再复用这个 fail-open 语义。
  3. 付款方解析。 ResolveFunding 先解析个人 plan,再严格读取 seat 和 membership;只有 seat 的 workspace 等于本次操作的 workspaceID 才返回 Funding.Pool。seat lookup 失败直接返回 error,而不是把操作错误地记到员工自己的 credits 上。PoolsForUser 则返回成员能触达的所有 workspace,供读路径显示。
  4. 任务提交。 image service 在 source asset、entitlement 和 reference 预检通过后调用 task.Submittask.Submit 在事务外解析 funding,在同一事务里写 task 并调用 credits.DebitTx;只有 task/debit 都提交后才执行 queue publish。publish 失败进入 Fail,再由 Refund 按原 debit 的 bucket 恢复余额。
  5. 任务 debit。 chooseDebitPayer 先无锁读取 workspace pool;池子余额不足整笔成本时完全回落 personal,避免一笔操作同时有两个付款方。看起来够付时才锁 pool,并在锁内复核;复核后不足仍回落 personal。余额更新和 ledger insert 在同一事务里完成,workspace_id 无论 personal 还是 pool 付款都记录。
  6. Agent turn。 Agent chat 通过 chat 的 workspace 调用 Go DebitAgentTurn,不是使用全局当前 workspace。worker 先读取 /api/credits/me:个人 totalAvailable 大于零,或 pools 中有当前 workspace 且 availableToMe > 0,才允许 turn 开始。turn 已经执行后才扣费;个人余额为零时,若 pool 有 residue,会允许 pool 扣到其剩余余额,避免 residue 让 gate 永远放行却不消费。
  7. Credits read model。 /api/credits/me.totalAvailable 仍然只统计个人 bucket;workspace pool 独立放在 pools[],不能把两者相加,因为相加后的数字没有明确的可消费 workspace。#5821 当前把 PoolView.AvailableToMe 设为 pool 总余额,daily holder limit 和 pool reset 仍是后续路径。

关键代码#

1. 先把“个人购买的 Plan”与“席位授予的能力”拆开#

PR #5817,backend/go/internal/billing/model.go:30-91;PR #5815,backend/go/internal/billing/model.go:94-118

// #5817: Plan 只剩个人购买阶梯。
const (
    PlanFree  = api.PlanFree
    PlanBasic = api.PlanBasic
    PlanPro   = api.PlanPro
    PlanMega  = api.PlanMega
)

// #5815: credits 只拿它需要的 billing facts。
type PlanResolution struct {
    Plan          Plan
    PriceKey      string
    Cycle         *BillingCycle
    PlanConfirmed bool
}
plaintext

PlanForPriceKey 对 team seat price 返回未识别,SeatTypeForPriceKey 才负责把价格归一到 Basic/Pro seat。这个边界让后续 ResolveFunding 可以同时返回个人 PlanResolution 和 workspace PoolFunding,而不需要让所有 Plan consumer 重新理解 team

2. bucket owner 使用数据库不变量表达,而不是靠 Go caller 自律#

PR #5771,backend/base/db/migration/000107_credit_buckets_workspace_owner.up.sql:25-33000108_credit_buckets_workspace_scope_index.up.sql:10-11

ALTER TABLE credit_buckets
  ADD COLUMN workspace_id UUID,
  ADD CONSTRAINT credit_buckets_one_owner
      CHECK ((user_id IS NULL) <> (workspace_id IS NULL)) NOT VALID,
  ALTER COLUMN user_id DROP NOT NULL;

CREATE UNIQUE INDEX CONCURRENTLY credit_buckets_workspace_scope
  ON credit_buckets(workspace_id, bucket_type) WHERE user_id IS NULL;
plaintext

XOR check 保证每一行恰好一个 owner;partial unique index 保证每个 workspace 每个 bucket type 只有一个 pool。旧 (user_id, bucket_type) unique constraint 刻意保留,因为滚动发布时旧 binary 仍会发出原来的 ON CONFLICT (user_id, bucket_type)

3. funding 路径不复用 entitlement 的 fail-open fallback#

PR #5821,backend/go/internal/billing/service.go:205-284

func (s *service) ResolveFunding(ctx context.Context, userID, workspaceID uuid.UUID) (Funding, error) {
    personal, err := s.resolveUserPlan(ctx, userID)
    if err != nil { return Funding{}, err }
    seats, err := s.seatsForUser(ctx, userID)
    if err != nil { return Funding{}, errors.Wrap(err, "failed to resolve seats for funding") }

    out := Funding{Personal: personal}
    for _, seat := range seats {
        if seat.WorkspaceID == workspaceID {
            out.Pool = &PoolFunding{WorkspaceID: workspaceID}
            break
        }
    }
    return out, nil
}
plaintext

同一个成员查询对两个业务问题采用不同错误策略:entitlement 的查询失败意味着“不要把所有用户的操作都锁死”,所以 activeSeats fail-open;funding 的查询失败意味着“不能确定谁应该付款”,所以必须报错。这个差异是风险边界,不是重复实现。

4. task debit 选择一个 payer,并在必要时才锁 workspace pool#

PR #5821,backend/go/internal/credits/service.go:378-408

candidate, err := s.repo.GetPoolBucketsNoLock(ctx, tx, pool.WorkspaceID)
if err != nil { return nil, err }
if totalBalance(candidate) < poolNeeds {
    return personal, nil
}

locked, err := s.repo.LockPoolBuckets(ctx, tx, pool.WorkspaceID)
if err != nil { return nil, err }
if totalBalance(locked) < poolNeeds {
    return personal, nil // unlocked read was stale
}
return locked, nil
plaintext

一笔 task 不拆给公司和员工:pool 能覆盖完整成本才使用 pool,否则完整回落 personal。无锁读避免不可能付款的请求触碰热点 pool;锁内复核避免并发成员把同一余额同时视为可用。FOR UPDATE 持有到事务提交,因此 task row、bucket update、ledger insert 的顺序必须在同一事务内完成。

Agent turn 有明确的不同边界:它是 post-hoc debit,个人余额为零时把 poolNeeds 降为 anyBalance = 1,从而让 pool residue 被消耗;个人还有余额时仍要求 pool 覆盖整笔 turn cost。这解决了 Agent gate 与实际扣费之间的“残余余额永远放行”问题,但也意味着 task 与 Agent 的不足额语义不同。

5. refund 以 debit 行指定的 bucket 为权威,而不是按 spender 重新猜#

PR #5821,backend/go/internal/credits/service.go:605-658backend/go/internal/credits/repo.go:198-216

for _, d := range debits {
    amount := abs(d.Amount)
    after, err := s.repo.CreditBucketBalance(txCtx, tx, d.BucketID, amount)
    if errors.Is(err, ErrBucketGone) { continue }
    if err != nil { return err }

    inserts = append(inserts, Transaction{
        UserID: userID, BucketID: d.BucketID,
        WorkspaceID: d.WorkspaceID,
        Amount: amount, BalanceBefore: after - amount, BalanceAfter: after,
        Kind: TxnRefund, TaskID: &ref,
    })
}
plaintext

旧路径按 spender 的个人 buckets 找退款目标;pool 付款时当然找不到,于是余额被静默烧掉。新路径沿 debit 的 BucketIDWorkspaceID 回退到真实付款方,并用 UPDATE ... balance = balance + ? RETURNING balance 做相对增量,不需要 refund 再锁 pool,避免和 pool debit 形成反向锁序。

6. worker gate 只接受当前 workspace 的 pool#

PR #5821,backend/workers/agent/src/goApi.ts:584-615;对应 UI 为 InsufficientCreditsNotice.tsx:29-71

const workspaceId = requireWorkspaceId(go);
const canPay =
  result.data.totalAvailable > 0 ||
  result.data.pools.some(
    (pool) => pool.workspaceId === workspaceId && pool.availableToMe > 0,
  );
plaintext

totalAvailable 不再是“账号能在任何地方消费的总额”;它仍是 personal-only。另一个 workspace 的 pool 不能放行当前 chat,否则 turn 可能在当前空间运行、ledger 却没有可用付款方。#5821 的 Go API 单测同时覆盖“只有匹配 workspace pool 放行”和“只有其他 workspace pool 仍拒绝”。

工程取舍#

边界与数据权威#

  • 能力和付款分离。 #5815/#5820 让 EntitlementsForUser 负责“能做什么”,#5821 的 ResolveFunding 负责“谁付款”,避免每个 image/task/agent caller 自己从 plan 或 seat 推断资金来源。
  • 三个 ledger 维度不互相替代。 user_id 是谁消费,bucket_id 是谁的余额移动,workspace_id 是在哪里发生;个人 bucket 在 workspace 中付款时也写 workspace,未来才可以做真实用量归因。
  • workspace 访问资格来自 seat + membership。 workspace_seats 的 active row 单独不够;seatsForUser 还要确认 owner/admin/member,排除由共享文件产生的 GUEST membership。

并发、性能与账务一致性#

  • pool 只在可能付款时加锁。 无锁候选读减少对 workspace 热点行的锁竞争;锁内重读是必要的,因为候选余额可能在两个读之间被其他成员消耗。
  • 不拆 payer。 这牺牲了“先花 pool 再花个人”这种表面上的额度利用率,却保留了一个 task 一个可逆付款对象,refund 不需要跨所有者分配。
  • refund 不反向锁 pool。 debit 锁 personal 后再锁 pool;refund 仍锁 spender 以保持 refund idempotency,但用 bucket id 的相对 update 写回 pool,避免两条路径形成锁序倒置。
  • 读取模型不做不可消费的总和。 pool 按 workspace 展开,totalAvailable 保持个人值;用户在 workspace 间切换时从同一 pools[] 找目标,不需要把多个 pool 合并成一个假余额。
  • 仍有明显的非原子窗口。 funding 在 task.Submit 事务外解析,之后才在事务里写 task/debit;余额锁只保护资金竞争,不会重新证明 seat 仍然有效。这是授权与账务边界之间还没有闭合的窗口。

兼容性与 rollout#

  • #5771 用单条 ALTER TABLE 增加 nullable workspace owner、外键和 XOR check,并使用 lock_timeout = '5s';两个 CREATE INDEX CONCURRENTLY 各自单独迁移,避免对热表长时间排队。
  • 新 FK/check 是 NOT VALID:现存行逻辑上满足约束,不需要在发布锁期间扫描整张表;但后续 VALIDATE CONSTRAINT 仍是运维责任。
  • 旧 personal unique constraint 不立即删除,保证旧 binary 在新 migration 后仍能解析原 conflict target。这个兼容性选择与 #5817 的契约删除不同:已写入数据和正在滚动的 SQL 行为不能靠 reload 修复。
  • #5833 的 workspace subscription/customer 和 webhook metadata 是独立的支付对象;PlanForPriceKey 仍不读取 team seat price,seat type 才是购买数量与额度策略的稳定 key。

测试策略#

  • backend/go/apps/api/contract_test/credits/e2e/pool_pays_test.go:13-184 用真实数据库行形状建立 seat、subscription 和 pool,验证 workspace 付款且个人余额不动、pool 退款、pool 不足整笔时不被触碰、pool 与个人余额分开报告、移除 membership 后 pool 不再可达。
  • backend/go/internal/credits/service_integration_test.go:204-357 覆盖 Agent turn 的特殊不足额语义、计划未确认时 pool 仍可付款、waived turn 的 COGS 行仍落在个人 bucket,以及 residue 不会持续放行而不扣费。
  • backend/workers/agent/src/goApi.test.ts 覆盖当前 workspace 匹配、错误 workspace 不匹配和认证/5xx fallback;UI notice 复用同一个匹配条件,避免 worker 放行但 UI 不给 retry。
  • #5833 的 checkout/webhook contract test 断言出站 Stripe form 的 subscription_data[metadata][workspaceId]、workspace customer 不带个人 userId、workspace row 在 Stripe 前 claim、stale team event 不撤销当前订阅,以及团队 invoice 使用团队通知卡片。
  • 这些测试证明了模块边界和若干真实 SQL/HTTP 行为,但不能替代部署级链路:测试 fixture 直接写 workspace_seats/credit_buckets,因为当前 API 仍没有 seat assignment 和 pool seeding。

和最近学习记录的关系#

  • #5771:workspace credit pool owner 与 ledger schema 是本次最直接的前置。昨天报告把“下一步必须证明 bucket selection、debit/refund、workspace attribution”列为缺口;#5821 正好补上了这三个 runtime seam,但仍没有补 pool 创建和 seat assignment。
  • #5455:insufficient credit telemetry boundary 关注用户遭遇额度不足时如何产生 typed event;#5821 改变的是不足前的 funding/gate 事实,不应把 workspace pool 的 ledger 付款当成前端 encounter event,二者的事实来源不同。
  • #5754:平台任务预算终止与自动续跑 是 Agent runtime 的交付状态线。本次只复用其“跨层 contract 要由 worker、Go 和持久化共同闭合”的观察,不把 Agent continuation 与 credits billing 拼成同一业务线。
  • #5683 Export Workflow 和 #5520 image delivery 都有“先做边界预检,再进入有副作用事务”的相似工程形态,但没有共享 billing/credits 数据模型,因此不作为本次正式 related PR。

我会怎么吸收#

  1. 先定义每个字段回答哪个业务问题。 把 money/price、capacity/seat type、owner/bucket、scope/workspace、capability/entitlement 分开,再设计 resolver;不要用一个 Plan 或一个 workspace_id 兼任所有语义。
  2. 用窄接口把跨模块事实集中到唯一入口。 ResolveFundingEntitlementsForUser 让 task、image、agent 不必复制 seat/membership/plan 判断,也使不同风险场景的 fail-open/fail-closed 策略可被显式评审。
  3. 把不可逆的账务选择放进单一事务。 task row 与 debit 同事务,publish 在 commit 后;失败按 debit 行 refund,避免队列失败造成“任务已扣费但无法找回付款方”。
  4. 并发优化不能牺牲复核。 先无锁读减少无意义锁,再在锁内重读确认;如果必须避免锁序倒置,优先用 debit 行作为退款目标和相对增量,而不是为了统一代码强行复用一套锁。
  5. 测试既要证明真实行为,也要标记 fixture 绕过的产品边界。 真实 Postgres/HTTP contract 能证明 bucket selection,但直接插入 seat/pool 的测试不能证明 checkout 到 assignment 的产品链路,报告和后续计划都应保留这个差异。

边界、风险与未解问题#

代码已经确认的边界#

  • #5821 的生产 task 路径把 workspaceIDFunding.Pool 带入 DebitTx;Agent chat 从 chat 所属 workspace 带入 DebitAgentTurn。这两条路径都会把 workspace 写入 debit ledger。
  • task debit 的 pool 规则是“能覆盖整笔才用”,而 Agent turn 的 pool 规则在 personal 余额为零时允许消耗 residue;两者都避免同一笔操作同时由两个 owner 付款。
  • pool refund 直接使用原 debit 的 BucketID;如果历史 bucket 已被 plan downgrade 删除,ErrBucketGone 会跳过该退款,避免把钱错误放入新 bucket。
  • /api/credits/me 的个人 totalAvailablepools[] 分离;Agent gate 和 UI retry 都按当前 workspace 精确匹配 pool。
  • #5833 的 workspace customer、checkout row claim、subscription metadata 路由和 team portal configuration guard 已存在;但 seat 变更接口仍明确返回 not implemented。

仍需确认的风险#

  • 资格与扣费之间的竞态。 ResolveFunding 在事务外执行,seat/membership 被撤销后,后续 debit 仍可能使用此前解析出的 pool。需要决定以 funding snapshot 为准,还是在事务内重新验证 membership/seat。
  • pool 生命周期未闭合。 workspace checkout/webhook 只维护订阅和 seat 商品信息;没有看到把 seat 数量、SeatMonthlyAllotment、workspace bucket 创建/重置接到同一事务的代码。
  • 每日上限未落地。 PoolView.AvailableToMe 目前返回 pool total;#5771 的 usage index 尚未被 daily limit 读取路径使用。多成员共享 pool 时,当前实现不能证明每个 holder 的 cap。
  • Agent 与 task 的不足额差异。 Agent 的 post-hoc charge 允许个人为空时扣 pool residue,task 则不允许 pool 部分支付;需要产品确认两种行为是否都符合“一个操作一个付款方”的长期账务定义。
  • 迁移收尾。 NOT VALID constraints 必须验证;旧 (user_id, bucket_type) conflict target 何时删除,以及 pool owner 的 down migration 在已有数据下如何安全执行,仍未由本线解决。
  • 可观测性。 ledger 有 workspace 维度,但没有看到“pool 被选中/因不足回落个人/因 seat lookup error 拒绝”的结构化业务指标;仅靠余额和 402 很难区分产品行为与故障。
  • 部署级验证。 需要真实 Stripe test clock/checkout、webhook 重排、seat assignment、Postgres 并发 debit/refund、Agent reconnect 和浏览器 workspace 切换覆盖,而不只是 fake Stripe 和直接 SQL fixture。

候选说明#

本次按 Australia/Melbourne 的 2026-08-10 合并窗口筛选。当天较有工程信号的候选包括 #5820(seat entitlement)、#5821(workspace pool funding)、#5833(workspace checkout/webhook)、#5837(Agent 读取页面背景)和若干 docs/UI PR。#5821 直接推进前一日 #5771 的未完成 runtime 缺口,且能自然串起 #5820 与 #5833,因此优先于孤立的 UI/文档变更。

日志中已学习过的锚点包括 #5771、#5754、#5683、#5591、#5520、#5503/#5507 等;本次没有重新选择它们。#5771 只作为前置背景和新增阶段对照,不重复把它当作今日锚点。

正式 related PR 限定为 #5817、#5815、#5771、#5820、#5833:它们分别共享 Plan/SeatType 契约、billing resolver、credit bucket/ledger schema、seat entitlement runtime 或 workspace Stripe user flow。六个 PR 的 merge commit 均通过 git merge-base --is-ancestor <commit> origin/main,且当前 Voyager origin/main82497c4398178fa744fd4150869bab88f67c01d4;本次不需要扩大到更早日期。

没有把 #5455、#5754、#5683 等仅有主题相似但不共享代码边界的 PR 塞进正式时间线;它们只在“最近学习记录”中作为对照。#5821 本身有 22 个文件和真实 Postgres/HTTP/worker 测试,因此这条线不是孤立变更,但完整产品链仍被 assignment、pool seeding 和部署级验证阻断。

GitHub 文档#

  • 报告文件:outputs/voyager-daily-pr-study/2026-08-10-pr-5821-workspace-pool-funding.md
  • 目标仓库:joyehuang/ai-agent-field-notes
  • Voyager 锚点:PR #5821
  • Voyager 锚点 merge commit:929bad10c614d5e6da7946c961adbafce3219cad
  • Voyager 阅读基线:origin/main = 82497c4398178fa744fd4150869bab88f67c01d4
  • 本报告与索引将在本次目标仓库提交中一起持久化;实际目标仓库 commit 以本次外部 study-log.jsonlgithubCommit 为准。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back