Skip to content

feat(runtime): durable Work Ledger rebased onto current master - #2270

Open
jasondeng1997 wants to merge 91 commits into
oceanbase:masterfrom
jasondeng1997:rebase/onto-master
Open

jasondeng1997 wants to merge 91 commits into
oceanbase:masterfrom
jasondeng1997:rebase/onto-master

Conversation

@jasondeng1997

@jasondeng1997 jasondeng1997 commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Which issue or RFC does this PR close?

Tracks RFC 1430 — distributed Server workers /
#1430. This is the runtime Work Ledger rebased onto current
master; it is deliberately narrowed to the ledger, its roles, and the transport that makes durable work observable.

Rationale for this change

Background processing had two problems. Every process that wanted to run Memory, Profile or Experience work needed its
own APScheduler sidecar and SQLite state, so two Servers could not coordinate; and a caller had no way to see what a
bounded Source window was doing, stop it, or recover it after a failure. The Work Ledger makes one logical Source window
a database row with a fenced lease, so any process can observe it and exactly one process can act on it at a time.

What changes are included in this PR?

Persistence. builtin/persistence/work.py adds the work item, lane and logical-key tables with an optimistic
state_version, database-time leases, fences, and the operations that move a window through queued → running → retry_wait → succeeded/failed/cancelled. coordination.py adds leader election and membership heartbeats.
migrations/server_resources/schemas/mysql-v1_4_1_006.json carries the schema (v1_4_1_006 avoids the number already
taken by #2192).

Runtime. DurableScheduler scans keyset-paginated discovery pages as a fenced leader; DurableWorker claims with
capacity, renews leases and recovers abandoned attempts; work_handlers.py implements the Memory, Profile and Experience
source-window handlers and the maintenance handler; composition.py assembles components by deployment role
(single_node keeps the APScheduler supervisors, distributed splits api / scheduler / worker / all), with each
Family owned by exactly one path. operations.py exposes the OperationManager facade.

Server. Deployment-role validation (including the third BuiltinConfig construction site in cli/config.py), the
scoped durable operation endpoints described below, a shared rate-limit middleware, and worker observability (OTLP spans
and model usage per attempt).

Docs. RFC 1430 (en/zh), deployment, configuration and HTTP API pages; the operation endpoints are documented with an
explicit statement of what is still proposal material.

Are there any user-facing changes?

Yes, three groups:

  1. New endpoints — durable operations are served under the owning Scope, so authorization resolves from the request
    and another Scope's row cannot be probed:

    • GET /v1/scopes/{scope_id}/operations — page public operations, filter by kind / status (scope.read)
    • GET /v1/scopes/{scope_id}/operations/{operation_id} (scope.read)
    • POST /v1/scopes/{scope_id}/operations/{operation_id}/cancel — expected_version required (scope.contribute)
    • POST /v1/scopes/{scope_id}/operations/{operation_id}/retry — failed operations only (scope.contribute)

    A stale expected_version returns 409 operation_conflict and leaves the row untouched. Another Scope's operation,
    and internal maintenance work, return 404.

  2. New configuration sections — deployment, coordination, worker, operations, rate_limit. A
    distributed deployment requires OceanBase for coordination and a generation_model on worker; host-local
    external Skill targets are rejected because they cannot be reproduced on another replica.

  3. Behavior — in single_node nothing changes: the APScheduler supervisors still own processing and the ledger is
    not opened. In distributed the ledger owns Memory, Profile and Experience windows. POST /v1/memory/flush is still
    synchronous (200); the 202 / Prefer: respond-async handle described in RFC 1430 is not implemented, and the
    docs now say so.

How was this change tested?

  • make contract-test → 56 passed (contract, generated Python transport, JS operations, Desktop digest consistent)
  • tests/test_operation_routes.py (new) → 2 passed: paging and filters, cross-Scope 404, maintenance 404,
    stale-version 409, cancel → cancelled, cancelled-is-not-retryable, and a genuinely failed window retrying to
    queued with cleared error fields
  • tests/test_server.py + tests/e2e/test_operation_api.py + docs contract tests → 70 passed
  • tests/builtin/runtime + tests/builtin/persistence + server metric/tracing/health suites → 1055 passed / 1 skipped
  • tests/test_config_cli.py → 45 passed (role validation)
  • tests/e2e/test_zcode_service_chain.py → passed repeatedly; the boundary-flush assertion now re-issues the bounded
    Stop flush instead of racing the hook's 800 ms budget
  • DSH plugin: pnpm test 428 passed, test:e2e 9 passed; the committed bundle is rebuilt from the regenerated catalog

AI usage statement

Implementation, tests and documentation were drafted with an AI coding assistant (WorkBuddy / CodeBuddy) under author
review; every command in the list above was run by the author on this head, and the contract, generated transport and
Desktop digest were regenerated and re-verified rather than hand-edited.

Rebase rounds after this description was written

The branch was rebased onto master 2291237a as 7d716b64, which brought in the 1.5.1 contract. The Desktop artifacts
and the protocol qualification were regenerated from that merge (253ee2ad, desktop/VALIDATION.md), not carried over.
Every review finding since then has its own commit and its own regression test; the commit-by-commit table, and the CI
attribution for the checks that are red on master itself, are in the newest review comment.

Verification that this host can run at 7db9ebeb: ruff check and ruff format --check over e2e/bub (44 files), the
generated ACP pin command executed end to end against a stub interpreter (shell quoting and probe compilation), and
git diff --check. The suites, the contract gates and the Docker-based acceptance run in CI: this host has no Docker
runtime, and local pytest is not usable in this environment.

Copilot AI balanced review requested due to automatic review settings October 27, 2026 12:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@hireview001 hireview001 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handoff_report: HandoffReportConfig = Field(default_factory=HandoffReportConfig)
inference: InferenceConfig = Field(default_factory=InferenceConfig)
external_skills: ExternalSkillsConfig = Field(default_factory=ExternalSkillsConfig)
deployment: DeploymentConfig = Field(default_factory=DeploymentConfig)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Wire deployment roles into server and runtime composition

create_server_app reconstructs BuiltinConfig without the new deployment settings, and open_builtin_runtime never starts DurableScheduler, DurableWorker, or RuntimeMembership, so distributed roles retain the existing process-local execution behavior and workers expose data-plane routes. Propagate the configuration and compose the promised role-specific services and routes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b311e14a. The wiring now exists end to end:

  • ServerSettings accepts deployment, coordination, worker, operations and rate_limit (src/powercontext/server/settings.py:238-242). It previously had none of them, so no environment variable could reach the ledger.
  • create_server_app passes all five into BuiltinConfig (server/factory.py:169-182), and so do the background runner and the offline migration command (server/cli.py).
  • open_builtin_runtime opens the ledger (composition.py:900), implemented by _open_work_background (composition.py:1241) with role gating:
scheduler = DurableScheduler(...) if role in ("all", "scheduler") else None
worker = DurableWorker(...) if role in ("all", "worker") else None

api therefore never scans or claims, scheduler never claims, and worker never scans. RuntimeMembership starts for every role that runs something, and shutdown stops the worker, then the scheduler, then membership, so an in-flight claim still commits under a valid lease.

One deliberate scope decision: a single_node/all deployment starts the ledger only when a background schedule is configured (_work_background_requested, composition.py:1145). An unconfigured install keeps exactly today's behaviour instead of acquiring an ownership lease and polling an empty queue. OperationManager is exposed as runtime.operations in every role, because reading, cancelling and retrying history is not role-specific.

Covered by tests/builtin/runtime/test_work_composition.py (role gating, discoverer and handler registration, a second runtime rejected with DuplicateSingleNodeError, clean shutdown).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked at 886a0cfb: configuration propagation and ledger component construction are present, but the route-isolation part of this finding is still reproducible. create_server_app() with deployment.mode="distributed" and either role="scheduler" or role="worker" still registers /v1/memory/flush and /v1/scopes/{scope_id}/operations, even with Dashboard and MCP disabled. The factory unconditionally builds the full data-plane app. This conflicts with the health/metrics-only role boundary in RFC 1430 and leaves a worker's provider-backed business routes reachable on its listener. Please gate the application/routes by deployment role and cover the actual factory output, rather than only testing _work_role_components() in isolation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closed by 33eb25e9 (route isolation). Distributed scheduler/worker now get the health route profile only — /health/live, /health/ready, /metrics — so neither /v1/memory/flush nor any business route remains reachable on those roles, and the health profile rejects Dashboard/MCP at composition time. tests/test_server_role_routes.py pins the exact route sets for every role.

"x-powercontext-scope-mode": "selection",
}
},
"/v1/operations": {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Implement the advertised durable operation endpoints

The generated schema advertises operation listing, retrieval, cancellation, and retry, but the canonical OpenAPI contract and server route registration contain none of them, so these requests return 404. Add the canonical contract and handlers, connect OperationManager to memory flush, and regenerate the transport definitions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still open — this is the one substantive item I have not closed yet, and I would rather say so than claim it.

State at b311e14a: the runtime side is now ready. OperationManager is composed for every role and exposed as runtime.operations (builtin/runtime/composition.py:1121), and MemoryWorkHandler drives durable Memory work through the ledger, so there is finally something to inspect, cancel and retry. What is still missing is the transport: openapi/powercontext.yaml has no /v1/operations paths, so scripts/generate_api.py --check regenerates nothing and server/app.py registers no handler.

The work I plan, in this order:

  1. Add the four paths to the canonical contract with Operation, OperationPage and an expected_version mutation body, plus the 202 + Location + Retry-After response on POST /v1/memory/flush that the docs already describe.
  2. Regenerate _generated/{models,operations,schema}.py and keep --check clean.
  3. Register the handlers in server/app.py through the same _add_route path as the other operations, mapping StoredWork to the response and keeping the _PUBLIC_KINDS whitelist so maintenance work stays private.
  4. Enqueue from the flush handler when Prefer: respond-async is requested, and notify the local worker.

One design question I do not want to guess on: these paths address work by operation_id, while the Scope lives on the stored row, so access cannot be resolved from the request alone. I plan to add a resolver that loads the operation and authorizes against its scope_id (scope.read for get/list, scope.contribute for cancel/retry). If you would rather keep the existing resolver vocabulary and require scope_id as a query parameter instead, say so and I will do that — it changes the contract shape, which is exactly the kind of thing I should not decide unilaterally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 11f4319c. The four endpoints are now canonical contract, generated transport, and live routes.

Route Access
GET /v1/scopes/{scope_id}/operations scope.read
GET /v1/scopes/{scope_id}/operations/{operation_id} scope.read
POST /v1/scopes/{scope_id}/operations/{operation_id}/cancel scope.contribute
POST /v1/scopes/{scope_id}/operations/{operation_id}/retry scope.contribute

The paths are Scope-scoped, and that was the open design question from my last comment. You did not answer it, so I picked the shape that does not weaken authorization: a durable row carries its own scope_id, and every access resolver in server/app.py is a pure function of path/query/body. Authorizing /v1/operations/{id} from the request alone is therefore impossible today — it would need a database read inside _authorization_dependency, or an unauthenticated identifier space. Under the owning Scope, the existing resolver vocabulary works unchanged and the path Scope becomes an ownership assertion in the handler.

What that buys, concretely:

  • another Scope's operation and internal maintenance work both read as 404, so ids cannot be probed for existence;
  • _PUBLIC_KINDS (builtin/runtime/operations.py) now covers Memory and Profile source-window families in addition to Experience, so a Scope owner can act on the work its own Sources produced while maintenance work stays invisible;
  • the response projects a safe subset of StoredWork — payload, logical key and lease internals do not leave the ledger;
  • cancel and retry take expected_version; a lost optimistic race is 409 operation_conflict and leaves the original state untouched (_map_governance_error now maps WorkStateConflictError and StaleWorkClaimError).

On "connect OperationManager to memory flush": I did not do that, and I corrected the docs instead of claiming it. The runtime side is ready — runtime.operations is composed for every role — but wiring flush to it means changing POST /v1/memory/flush from a synchronous 200 FlushMemoryResponse to a 202 handle with Location/Retry-After, which is a client-visible response-model change (the generator only accepts multiple success codes when they share one model). That is a contract decision I did not want to slip into a rebase PR. So:

  • docs/{en,zh}/docs/develop/http-api.md now documents exactly these four endpoints and states that flush still answers 200 synchronously;
  • docs/{en,zh}/rfcs/1430_distributed_server_workers.md keeps the proposal but now carries an implementation-status paragraph naming what is not shipped: 202 OperationAccepted, Prefer: respond-async, Prefer: wait=N, 409 operation_blocked, and the client submit_memory_flush() / polling helpers.

Evidence at this head:

  • tests/test_operation_routes.py (new, 2 tests): list paging and filters, kind/status scoping, cross-Scope 404, maintenance 404, stale-version 409, cancel → cancelled, cancelled-is-not-retryable 409, and retry of a genuinely failed window returning to queued with the error fields cleared;
  • make contract-test → 56 passed (contract, generated Python, JS operations, Desktop digest all consistent);
  • tests/test_server.py + tests/e2e/test_operation_api.py + docs contract tests → 70 passed;
  • DSH plugin pnpm test green after the regenerated catalog.

Two generated-artifact notes, in case they look surprising in the diff: the schema is named DurableOperation rather than Operation because a model named Operation collides with the generator's own route generic (Operation[Request, Response]) and silently pointed response_type at the generic; and OperationStatus is a named schema so the generator cannot invent a colliding Status2 — the first attempt did exactly that and renamed an unrelated model's enum.

)
_require_complete_window(rows, payload)
_, source_catalog = services.sources()
memory = services.memory(source_catalog)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Adapt durable memory work to the current Atomic Memory service

For an unconsumed source window, MemoryWorkHandler calls services.memory, which does not exist on _ScopedServices; its subsequent memory_artifact_id and collection planning assumptions also belong to the previous memory implementation. Prepare and commit through the current Atomic Memory service so durable memory work can complete instead of exhausting retries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored in b311e14a. MemoryWorkDiscoverer (work_handlers.py:110), MemoryWorkHandler (work_handlers.py:267) and memory_work_spec (work_handlers.py:540) are back, and they no longer touch _ScopedServices.memory, memory_artifact_id or the old collection planning.

The claimed window now runs through RelationalContexts.process_memory (runtime/relational.py:1544), which is the current Atomic Memory path: it takes the per-scope activation lock and calls AtomicMemorySourceWindowProcessor.flush, so the model calls stay outside the transaction and the processor keeps the single transaction that commits the cursor and the artifacts. MemoryWorkHandler.prepare only decides the outcome:

  • already_committed when the cursor already passed the claimed window,
  • superseded when a racing window consumed the prefix (the key is released instead of retried),
  • processed/idle from the MemoryFlushResult,
  • memory_conflict as a retryable WorkExecutionError for AtomicMemoryConflictError/GenerationConflictError/RevisionConflictError.

A retried attempt re-reads the cursor, so a window that already committed retires rather than repeating the domain write — at-least-once model calls, at-most-once commit, as the RFC states.

Covered by tests/builtin/runtime/test_memory_work.py: the spec tracks the unconsumed window, the handler drives the flush once, and a second attempt reports already_committed after the cursor advanced.

self._payload_version = payload_version

async def page(self, continuation: str | None, limit: int, /) -> DiscoveryPage:
scopes = await self._contexts.scope_ids_page(continuation, limit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Provide the scope pagination method used by discovery

MemoryWorkDiscoverer and ExperienceWorkDiscoverer call scope_ids_page, but RelationalContexts has no such method, so their first discovery page fails before enqueueing any work. Implement bounded scope pagination or adapt both discoverers to a supported interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already satisfied at this head: RelationalContexts.scope_ids_page exists (runtime/relational.py:1402) and both MemoryWorkDiscoverer (work_handlers.py:131) and ExperienceWorkDiscoverer (work_handlers.py:170) use it for their first page. tests/builtin/runtime/test_work_composition.py now asserts that both discoverers register, so an interface regression fails a unit test rather than the first discovery page.

coordination: CoordinationConfig = Field(default_factory=CoordinationConfig)
worker: WorkerConfig = Field(default_factory=WorkerConfig)
operations: OperationsConfig = Field(default_factory=OperationsConfig)
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Install the limiter when rate limiting is enabled

ServerSettings accepts rate_limit.enabled=True, but create_server_app neither installs SharedRateLimitMiddleware nor binds SharedRateLimiter, leaving requests unlimited regardless of the configured budget. Connect the limiter to the application lifecycle and run it after principal binding.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b311e14a. SharedRateLimitMiddleware is new (server/rate_limit.py:43) and create_server_app installs it when rate_limit.enabled is set (server/factory.py:368), appended after the authentication middleware so current_principal() is already bound.

Behaviour: the fixed window lives in the deployment database (RateLimitRepository.consume), so replicas share one budget; the key is a SHA-256 of principal.type:principal.id, falling back to the client host when no principal is bound; /health/live, /health/ready and /metrics are exempt; a rejected request returns 429 rate_limited with Retry-After.

tests/test_server_rate_limit.py drives a real app: requests=1 allows the first request and rejects the second with a positive Retry-After, the readiness probe still returns 200, and with enabled=False three requests are never rejected.


def _start_configured_server(settings: ServerSettings, scheduler_path: Path) -> RunningServer:
app = create_server_app(settings=settings, scheduler_path=scheduler_path)
def _start_configured_server(settings: ServerSettings) -> RunningServer:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Update the remaining caller of the server helper

The helper now accepts one positional argument, but test_context_text_assembly.py still passes settings and a scheduler path, preventing that configured acceptance scenario from starting its server. Update the remaining caller or preserve the compatible signature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one does not reproduce at this head. _start_configured_server still takes two positional parameters — (settings: ServerSettings, scheduler_path: Path) (tests/e2e/real_experience_skill/harness.py:2027) — and every caller passes both, including test_context_text_assembly.py:111, test_profile_context_assembly.py:83 and test_native_code.py:376,483. The rebase kept the signature and the call sites consistent, so no acceptance scenario is blocked here. If you see the failure again, the job log would help me find the path I missed.

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 1ee3cdc47b055f734ca001830f44cb5a9e07d6f5. This is not ready to merge.

I reproduced the existing role composition issue, missing Operation routes, obsolete Memory service calls, missing scope pagination, and inactive rate limiting. Operation GET/list return 404; discovery and Memory preparation raise AttributeError; a one-request rate limit still permits the second request. I am keeping those findings in their existing threads.

The inline comment adds a separate migration-test collection failure. scripts/generate_api.py --check also fails because the checked-in generated API does not match the canonical contract. Please complete the runtime/transport wiring and migration acceptance before seeking approval. Validation here used isolated SQLite and ASGI; it does not establish real OceanBase multiprocess acceptance.

Comment thread tests/test_server_migration_cli.py Outdated

from typer.testing import CliRunner

from powercontext.builtin.persistence.migration import CURRENT_SCHEMA_REVISION

@hidb4ai hidb4ai Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use a migration module that exists in the package

python -m pytest --collect-only tests/test_server_migration_cli.py fails at this import with ModuleNotFoundError: No module named 'powercontext.builtin.persistence.migration'. This PR adds the test but no such module, so a normal test-suite collection aborts before migration behavior can be checked. Update the test to use the shipped migration API and revision contract, or include the missing implementation, so the acceptance scenario can actually run. The new Alembic migrations/env.py and versions/0001_baseline.py also import this absent module.

The invocation on line 39 also exits with code 2: No such command 'migrate'. The CLI registers server db-migrate, so updating only the import would still leave this scenario unable to exercise a migration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by the rebase. tests/test_server_migration_cli.py no longer imports powercontext.builtin.persistence.migration; it uses the shipped API (powercontext.cli.app.create_cli plus powercontext.server.cli.app) and invokes the registered command, server db-migrate plan / apply (tests/test_server_migration_cli.py:15-16,39). There is no 0001_baseline.py in the tree, and neither server_resources/env.py nor mysql/env.py imports the absent module — they only configure Alembic against the executor's pinned connection.

python -m pytest tests/test_server_migration_cli.py passes here (it is part of the 46-test migration run), and scripts/generate_api.py --check is also clean at this head.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test import and its db-migrate plan/apply invocation are fixed and pass at 886a0cfb. One shipped caller still needs the same correction: docker/compose.distributed.yaml:18 runs ["server", "migrate"], and both deployment guides tell operators to run that command. The exact-head CLI exits 2 with No such command 'migrate'. Every API/Scheduler/Worker service depends on that migrator completing successfully, so the documented Compose topology cannot start. Please adapt the migrator to the supported explicit db-migrate plan/apply workflow, including its acceptance requirements, and update both guides and the Docker contract test that currently asserts the invalid command.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adapted in c9934b701, and it is now covered end to end instead of by string matching:

  • docker/compose.distributed.yaml:15-45 — the one-shot migrate service runs powercontext server db-migrate plan with --evidence-dir /var/lib/powercontext/migration-state --lock-coordination single-host --backup <policy>, reads plan_id from its JSON output, then exec powercontext server db-migrate apply --plan-id ... --maintenance-confirmed --yes. The backup policy defaults to manual and a skip run has to pass --accept-no-backup, so a missing backup cannot pass silently. Container-side shell variables are escaped as $$, because Compose otherwise interpolates $policy to an empty string before the script runs.
  • docs/en/docs/operate/deploy-server.md, docs/zh/docs/operate/deploy-server.md, and both operate/database-migrations.md — the operator instructions use the same plan/apply pair.
  • tests/test_docker_contract.py:76-86 — asserts the migrator is /bin/sh -ec running server db-migrate plan followed by server db-migrate apply with --plan-id, the evidence directory, --lock-coordination single-host, --maintenance-confirmed and --yes.
  • tests/test_docker_contract.py:130-190 — renders the Compose script the way Compose does (including host interpolation) and executes it against a stub powercontext binary, then asserts the argv it receives: server db-migrate apply, --plan-id plan-abc, --backup manual --backup-confirmed by default and --backup skip under POWERCONTEXT_MIGRATION_BACKUP_POLICY=skip. This is the regression that fails if the script regresses to server migrate.

Head a25b6adf3.

Copilot AI balanced review requested due to automatic review settings October 29, 2026 04:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

Re-apply the Work Ledger onto master's migration framework, which replaced
Alembic revision discovery with frozen bundles (detached DDL, fingerprints and a
single linear `down_revision` chain). The ledger keeps its own tables and
semantics; only the registration changes.

Registration

* The revision is registered as `v1_4_1_005` and follows `v1_4_1_004` instead of
  `s1402`. Master's owner-recovery revision already occupies the slot after
  `s1402`, and the bundle rejects a second head, so the ledger appends to the
  single chain. The release-based name follows the naming rule that revision
  introduced, which keeps `server db-migrate status` able to describe the target.
* Both manifests (`server_resources`, `server_sources/1.4.0`) register the
  revision resources, its fingerprint, its readiness entry and the two MySQL
  state layouts, keeping master's `v1_4_1_004` entries intact.
* The SQLite fingerprint is measured by replaying `s1300` through the new head;
  it changed only because the recorded layout now includes
  `pc_access_owner_recovery`. The persistence review digest in
  `release/upgrade-support.json` is recomputed and the rationale states what the
  revision adds.

Tests

* Migration tests assert the extended chain
  (`s1400, s1401, s1402, v1_4_1_004, v1_4_1_005`) and the new head revision.
* `previous_bundle` and the owner-recovery test now drop later revisions through
  one shared `drop_revision` helper, in head-first order, so the synthetic
  "before" bundle keeps a linear chain.

Verified: `make check` (prek, ruff, ty, integration manifest), the full
`tests/builtin/persistence` suite (691 passed, 110 skipped) and the migration CLI,
revision-name and maintenance CLI suites.
Copilot AI balanced review requested due to automatic review settings October 29, 2026 11:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@jasondeng1997

Copy link
Copy Markdown
Contributor Author

Scope update after the rebase: this PR is now the runtime Work Ledger

Rebasing onto current master forced two structural calls, both recorded in the latest commit:

1. The memory work kind is removed. Master now owns Source-to-Memory activation in the atomic memory pipeline (AtomicMemorySourceWindowProcessor), which opens its own transactions inside flush(). That is incompatible with this PR's contract — extraction must happen outside the fenced transaction and commit inside it, sharing the caller's transaction with the ledger write. Bridging would have meant reimplementing the deleted two-phase MemoryService, fighting master's architecture. The ledger's semantics don't depend on which work kind drives them: the Experience handler shares the same cursor helpers, the same superseded-snapshot retirement rule, and the same prepare/commit split, so the end-to-end tests (test_durable_experience_work.py) now ride Experience. Profile remains the second integrated kind.

2. The operations HTTP layer is deferred. The /v1/operations spec paths, generated clients, rate-limit middleware and server-role settings all assumed app/factory wiring that master has rewritten (that wiring is the remaining integration work). Shipping spec endpoints no server mounts would be dishonest, so the spec, generated code, JS integrations and the desktop contract are restored to master verbatim. OperationManager survives as the runtime control plane — submit/wait/list/cancel/retry with optimistic versioning — now covered directly by test_operation_api.py at the runtime level. The server mounting comes back as its own commit once the composition/factory assembly lands.

What the ledger itself gains from master's rework: scope_ids_page is restored for paged discovery, the two database primitives the ledger claims on (database_now, insert_if_absent) are restored beside master's engine layer with their semantics documented, the dead Alembic scaffolding is removed in favor of the frozen v1_4_1_005 bundle revision, and the tracing/metrics instrumentation is re-applied onto master's rewritten server/tracing.py and server/metrics.py.

3. The revision is v1_4_1_005, not s1403. Master replaced Alembic revision discovery with frozen bundles that require one linear chain, and its v1_4_1_004 owner-recovery revision already occupies the slot after s1402. The ledger therefore appends behind v1_4_1_004, and it carries a release-based name because that is the naming rule master introduced with v1_4_1_004: a legacy-style id would leave server db-migrate status unable to describe the target. The SQLite fingerprint is measured by replaying s1300 through the new head, so it changed only because the recorded layout now includes pc_access_owner_recovery.

Local verification on the rebased commit: make check green (prek, ruff, ty, upgrade policy, integration manifest 61 passed), tests/builtin/persistence 691 passed / 110 skipped, tests/builtin/runtime 961 passed, contract + ledger e2e 53 passed, migration/revision-name/maintenance CLI suites 37 passed. The operations HTTP layer, its spec and the server-role wiring come back as separate commits; nothing in this PR mounts routes it cannot serve.

"schemas/sqlite-v1_4_1_004.json",
"schemas/mysql-v1_4_1_004.json"
],
"v1_4_1_005": [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 迁移版本号 v1_4_1_005 与并行 PR #2192 撞号,两者是内容不同的 revision

本行注册的 v1_4_1_005(work ledger)与 #2192(head 27d499e0)注册的 v1_4_1_005(atomic memory description)同号不同内容:

项 本 PR(head dd80249b) #2192(head 27d499e0)
版本模块 versions/v1_4_1_005_work_ledger.py versions/v1_4_1_005_atomic_memory_description.py
sqlite_fingerprints["v1_4_1_005"] 92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6 2e24be9f699c202cb9d7985690b2be947bdc98f8f64c1c4e4b8e16e35a580fc4
release/upgrade-support.json 的 persistence_sha256 581696bc…(本 head 复算一致) cbbf2858…(同 head 复算一致)

两个 PR 还同时改 migrations/server_sources/1.4.0/manifest.json 的同一处追加,以及 release/upgrade-support.json 的同一个摘要值。

影响:任一方先合,另一方 rebase 时这三处必然文本冲突;若按“两边都保留”解决,同一个 revision 名会对应两份不同 DDL 与两个不同 fingerprint,离线迁移校验按 revision 名取 fingerprint,升级链基线取到哪一份取决于合并顺序。两边的 CI 各自为绿,缺陷只在合并后出现。

建议:本 PR 或 #2192 任一方改用下一个未占用编号(如 v1_4_1_006),同步 server_sources/1.4.0/manifest.json 与 sqlite_fingerprints,并按 scripts/upgrade_release_gate.py::managed_digest 重算 persistence_sha256;合入顺序确定后另一方 rebase 复算。

本次已核对的部分(不构成 Finding):本 head 的 managed_digest 与 release/upgrade-support.json 的声明值相等;tests/test_migration_revision_names.py、test_full_server_migrations.py、test_portable_storage_migration.py 在 exact head 副本上 27 passed,迁移资源自身自洽。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to v1_4_1_006 (9683d25e). #2192 opened on 2026-10-22 and is 29/30 green, so it keeps 005; this PR takes the next free number.

Item Now
Version module versions/v1_4_1_006_work_ledger.py
MySQL fragment mysql/versions/v1_4_1_006.py
Frozen schemas schemas/{sqlite,mysql}-v1_4_1_006.json
down_revision unchanged: v1_4_1_004
sqlite_fingerprints["v1_4_1_006"] 92eaad28… (unchanged, see below)
persistence_sha256 fcc35b91… → 96fc678e14fa0cc85ee6668169b860bd345d0bc40f7f31e11c82291d5fdfaf56

Two details worth recording:

  • The SQLite fingerprint did not change. sqlite_inventory excludes pc_schema_revision and the retained objects, so the revision name never reaches the digest. I re-verified by replaying s1300 → head through the real bundle and comparing every recorded value: all six match, including v1_4_1_006.
  • down_revision deliberately stays at v1_4_1_004. If feat(recall): add bounded atomic memory manifest #2192 lands first, rebasing will conflict here and I will point it at that v1_4_1_005 instead of merging both into a two-child graph.

tests/test_migration_revision_names.py (target now sequence: 6), test_full_server_migrations.py, test_portable_storage_migration.py, test_owner_recovery_migration.py, test_release140_server_migrations.py and test_server_migration_cli.py pass (46 passed), and scripts/generate_api.py --check is clean.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 改号到 v1_4_1_006 后,该编号已被 master 占用:合并结果是 add/add 冲突

回复里的判断在 2026-10-29 成立,但 master 随后合入了 source-write-plugins,v1_4_1_006 已被占用。同一根因在当前 head 上仍然存在。

项 本 PR(head 886a0cfb) master 176b3f92
版本模块 versions/v1_4_1_006_work_ledger.py versions/v1_4_1_006_source_write_plugins.py
down_revision v1_4_1_004 v1_4_1_005
sqlite_fingerprints["v1_4_1_006"] 92eaad28512ba8a03917acdd9a0996523a67450092e03161eca68e4e0f9d4cb6 cacce80bdfb14aff889b7babebc9ab8487b7f0a11d2dbbd3d77a3717a23df74c
revision_resources["v1_4_1_006"] schemas/{sqlite,mysql}-v1_4_1_006.json 同名同键
链尾 停在 v1_4_1_006 另有 v1_5_0_001(down_revision = v1_4_1_006)

证据按固定 OID 取得,未用工作树判断行尾:

  • git merge-tree --write-tree master refs/pr/2270(master 176b3f92,head 886a0cfb,merge-base bdf99789)报 31 处冲突,其中 CONFLICT (add/add) 三处正是 mysql/versions/v1_4_1_006.py、schemas/mysql-v1_4_1_006.json、schemas/sqlite-v1_4_1_006.json;manifest.json 与 release/upgrade-support.json 为 content 冲突。
  • 两边 revision_resources 与 sqlite_fingerprints 都以 v1_4_1_006 为键。按「两边都保留」解冲突后,后写覆盖前写,同一 revision 名会对应两份不同 DDL 与两个不同 fingerprint——正是原 Finding 里的第 ① ② 条失效机制,不会因为改名一次而消失。
  • 链形也有断点:master 为 004 → 005 → 006 → v1_5_0_001,本 PR 为 004 → 006。若只解文本冲突而不改链,本 PR 的 v1_4_1_006 不在 master 那条链上。

本 head 自身仍然自洽,所以缺陷只在合并后出现,与本 PR 的 CI 结果无关:tests/test_migration_revision_names.py + tests/builtin/persistence/test_full_server_migrations.py 在本 head 的 exact 副本上 23 passed;scripts/upgrade_release_gate.py::managed_digest 复算 = 96fc678e14fa0cc85ee6668169b860bd345d0bc40f7f31e11c82291d5fdfaf56,与 release/upgrade-support.json 声明值相等。

修复方向:改号到当前未占用的 id(v1_4_1 系列下一个空号 v1_4_1_007,或按发布语义取 v1_5_0_002),down_revision 指向 master tip v1_5_0_001;同步 versions/、mysql/versions/、两个 schemas/*、manifest.json 的 revision_resources / sqlite_fingerprints / affected_objects,按 managed_digest 重算 persistence_sha256,并同步 tests/test_migration_revision_names.py 里 sequence 与待应用列表的期望值。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本条状态更新(head 5fa749d8):d9f33be3 把 ledger 从 v1_4_1_006 改成 v1_4_1_007,与本条指出的“编号已被 master 占用 → add/add 冲突”这一半已经解除,manifest.json 里也不再有重名注册。

但改号只解决了“名字撞车”,没有解决“位置”:v1_5_0_001 是 master 当前的已登记 head,而本 PR 把它的 down_revision 从 v1_4_1_006 改成 v1_4_1_007,等于在已发布 head 之前插入一个新 revision。已经 stamp 成 v1_5_0_001 的库仍是 head,v1_4_1_007 永远不会执行。这条的完整证据与建议写在 v1_5_0_001_artifact_storage.py:23 的线程里(Copilot 提出、我补充了 master/head 对照与 #2395 的发布目标)。

本条按“重名已解”记录,不再单独挂 P1;剩下的链位置问题以那条线程为准。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved for good in 20bd4624. Master also took v1_4_1_006 (source-write-plugins) and then v1_5_0_001 (artifact storage), so the ledger no longer fights for a number at all: it is renamed to v1_5_0_002 and appended after v1_5_0_001 instead of being inserted before it.

Item Now
Version module versions/v1_5_0_002_work_ledger.py
MySQL fragment mysql/versions/v1_5_0_002.py
Frozen schemas schemas/sqlite-v1_5_0_002.json / schemas/mysql-v1_5_0_002.json
down_revision v1_5_0_001

Both manifests chain v1_5_0_001 -> v1_5_0_002; the v1_5_0_001 fingerprints are restored to master's 8f2d4a32…, and v1_5_0_002 carries the ledger's 3cf07deb…. test_migration_revision_names.py asserts pending("v1_5_0_001") == ("v1_5_0_002",) and the new head.

Reproduction with the real resources: a database built from base 745ebf2e and stamped at v1_5_0_001 verifies ready with no pc_work_items; upgrading through this bundle lands exactly one pending revision (v1_5_0_002) and apply returns state=ready, revision=v1_5_0_002, server_ready=true with all eight ledger tables present.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本条的「编号与链位置」在 20bd4624 之后已解除,核对如下(head a8cb90ee):

  • versions/v1_5_0_001_artifact_storage.py 的 blob 与 master 63e9665c 完全相同(a8dd25e4…),即没有再改写已发布 head 的 down_revision。
  • server_resources/manifest.json 相对 master 只多出 v1_5_0_002 一个键;master 已有键逐条不变(v1_5_0_001 = 8f2d4a32…、v1_4_1_006 = cacce80b…),v1_4_1_007 在 manifest 中已 0 命中。
  • 按 revision/down_revision 静态走链为单线:s1300 → s1400 → s1401 → s1402 → v1_4_1_004 → v1_4_1_005 → v1_4_1_006 → v1_5_0_001 → v1_5_0_002,v1_5_0_001 的后继只有一个;tests/test_migration_revision_names.py 在 exact head 上 7 passed。

本条按「已解」结束。同一改名引入的另一处问题不在迁移链上:release/upgrade-support.json 的 persistence_sha256 没有重算(managed_digest 复算 1b482cd3… ≠ 声明 0577c14e…,load_policy() 抛 GateError),已在新线程 4463726794(release/upgrade-support.json:6)留证,请按那条处理。

MySQL reflects `BOOL` as `tinyint(1)`, but the frozen state layout for the new
revision declared the column as `bool`. `MySQLSchema.recovery()` compares the
live inventory against every registered statement prefix after each DDL
statement, so once `pc_work_items` was created the schema looked like an
uncontrolled state and every MySQL-family upgrade aborted with
`incompatible_schema: Actual MySQL schema is not a controlled next-revision DDL
state`. SQLite was unaffected, which is why only the seekDB and OceanBase jobs
failed.

Register the column the way the rest of the frozen chain does, `TINYINT(1)`, and
declare the matching reflected type in the layout.

Verified by replaying the revision against a live MySQL server: every one of the
sixteen intermediate prefixes now matches the reflected inventory exactly, and
`recovery()` returns the expected pending statements from the previous head.
`make check` and the persistence suite stay green.
Copilot AI balanced review requested due to automatic review settings October 29, 2026 12:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

… upgrade

The service lifecycle and OceanBase engine-recovery suites asserted that a
completed upgrade reports `v1_4_1_004`. The rebased ledger appends
`v1_4_1_005`, so those assertions now compare against the actual head. Both
jobs failed only on that comparison; the database was already `ready`.
Copilot AI balanced review requested due to automatic review settings October 29, 2026 12:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

PR oceanbase#2192 registers `v1_4_1_005` for the atomic memory description revision and
opened six days earlier, so it keeps that number. Two open pull requests
claiming one revision name would ship two different DDL sets and two different
offline fingerprints under a single name, and the winner would depend on merge
order. This PR moves to the next free number instead.

The chain is otherwise unchanged: `down_revision` stays `v1_4_1_004`, the SQLite
fingerprint is unchanged because inventory never contains the revision name, and
`release/upgrade-support.json` is re-digested with
`scripts/upgrade_release_gate.py::managed_digest`. When oceanbase#2192 lands first this
revision will rebase onto it and point `down_revision` at its `v1_4_1_005`.

Verified with `tests/test_migration_revision_names.py`,
`test_full_server_migrations.py`, `test_portable_storage_migration.py`,
`test_owner_recovery_migration.py`, `test_release140_server_migrations.py` and
`test_server_migration_cli.py` (46 passed), plus a full `s1300`-to-head replay
that reproduces every recorded fingerprint.
The ledger modules, the Work handlers and the OperationManager all existed, but
nothing in `src/` ever constructed them: `create_server_app` rebuilt
`BuiltinConfig` without the deployment, coordination, worker and operations
sections, `open_builtin_runtime` never started a scheduler, worker or member,
and `ServerSettings` did not accept those sections at all, so a distributed
deployment silently kept the single-process behaviour.

Composition now owns the lifecycle:

- `ServerSettings` accepts `deployment`, `coordination`, `worker`, `operations`
  and `rate_limit`, and the Server, the background runner and the offline
  migration command pass them into `BuiltinConfig` instead of taking defaults.
- `_open_work_background` starts `RuntimeMembership` plus the role's components:
  `all` runs the scheduler and the worker, `scheduler` and `worker` run only
  their own, and `api` never scans or claims work. A single-node deployment
  starts the ledger only when a background schedule is configured, so an
  unconfigured install keeps its current behaviour. Shutdown drains the worker
  before leadership and membership disappear.
- `runtime.operations` exposes the `OperationManager` so the HTTP layer can
  inspect, cancel and retry history regardless of role.

`MemoryWorkHandler` and `MemoryWorkDiscoverer` are restored against the current
Atomic Memory service: a claimed window runs through
`RelationalContexts.process_memory`, which keeps the model calls outside the
lock and lets the processor own the single transaction that commits the cursor
and the artifacts. A retried attempt re-reads the cursor, so a window that
already committed retires instead of repeating the domain write.

`SharedRateLimitMiddleware` closes the other half of the advertised behaviour:
`rate_limit.enabled=True` used to leave requests unlimited. The window lives in
the deployment database, runs after principal binding, keys a bound principal by
type and id (falling back to the client host), and exempts liveness, readiness
and metrics.

Verified with `tests/builtin/runtime/test_work_composition.py`,
`test_memory_work.py` and `tests/test_server_rate_limit.py` (8 passed) plus the
existing `tests/builtin/runtime` and Server suites.
Copilot AI balanced review requested due to automatic review settings October 29, 2026 13:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

…clusive

The ledger was started for any `single_node` install that configured a
background schedule. The artifact-processing supervisor starts for exactly the
same configurations (`runtime/artifact_processing_role` must be `all`), so both
paths claimed the same source window in one process: the ledger advanced the
cursor, and the supervisor's next tick then saw an empty window and recorded a
noop instead of doing the work. That is what pushed
`tests/e2e/test_worker_otlp.py` and `tests/e2e/test_worker_model_usage.py` into
`outcome=noop` with no model spans.

RFC 1430 already fixes the ordering: the bridge release moves `single_node/all`
onto the ledger only once the APScheduler path has fully retired. Until then:

* `_work_background_requested` is true only for an explicit `distributed`
  deployment, so a `single_node` install keeps the supervisors it has today and
  only exposes the `OperationManager` facade.
* `_ledger_families` reports the Families the ledger drives, and
  `_supervisor_bindings` drops exactly those, so a distributed run never lets the
  supervisor compete for a window the ledger already owns.
* `_work_role_components` states the role split as a pure function: `api` runs
  neither component, `scheduler` only discovers, `worker` only claims, `all`
  does both.

The role wiring, membership, rate limiting and Memory ledger handler added
earlier are unchanged; only the activation boundary moved.

Verified locally: the four `test_worker_otlp` and six `test_worker_model_usage`
cases that failed on `b311e14a` now pass (10 passed), and
`tests/builtin/runtime/test_work_composition.py` covers the new boundary.
The repository test proves the state transition; this covers what the reviewer
observed over HTTP: a running operation answers 200 cancelling on the first
cancel, the replay with the returned version answers 200 with the same state,
and a stale version still answers 409 operation_conflict.
Skipping validation for a disabled schedule let a genuinely invalid expression
through, which contradicts the startup contract the profile scheduler test
already pins. Keep validating every configuration and widen the accepted syntax
instead: 'last' is the APScheduler day-of-month marker existing single-node
schedules were written with.
opendal-test runs outside the project with an unpinned '--with ty', so a new
ty release added a rule the repository gate has not adopted and flagged the
dynamic TextEvidence alias in the Connector as discarding its own fields. Pin
the version uv.lock already resolves so both checks agree.
`harness-validation / Acceptance (sqlite)` fails before any step's agent
starts. Run 42687710011, step 1/4:

    File "/installed-agent/acp_runner.py", line 16, in <module>
      from acp import PROTOCOL_VERSION, RequestError, spawn_agent_process, text_block
  ImportError: cannot import name 'text_block' from 'acp'

`text_block` lives in Harbor's own `acp_runner.py`, not in this repository,
so the failure is not in the Work Ledger routes the harness exercises. The
runner environment is built by Harbor's container setup with a bare
`pip install agent-client-protocol`, and that unpinned resolve now installs
a release that no longer exports the symbol. The same harness workflow
passed on master on 2026-11-07 (runs 42365885788, 42366219630), before that
release existed.

The ACP server this repository installs is already pinned to
`agent-client-protocol==1.0.0rc2` (`ACP_SDK_REQUIREMENT`), because
`bub-acp-server==0.1.3` pins that pre-release; the runner and the server are
the two halves of one protocol and must agree. Name the same requirement in
Harbor's runner venv after its dependency step, and print the SDK version
and the symbols the runner imports, so the setup log identifies a mismatch
instead of leaving it to appear as "No ACP summary file found" four times.

Verified: `ruff check` and `ruff format --check` clean on the changed
harness files; the generated command survives `sh` quoting end to end and
its probe compiles (checked against a stub interpreter, because this host has
no Docker runtime). `make harness-check` in CI runs the new assertion. This
host cannot run the acceptance suite, so the container outcome is verified by
the next CI run, not here.

No product code, contract, or persisted state changes.
@jasondeng1997

Copy link
Copy Markdown
Contributor Author

Status at a25b6adf3

Fixed in this round, each with the test that holds it

commit change regression proof
c8b62cf6 narrow the Memory publication context so ty can see principal tests/builtin/runtime/test_memory_work.py (isinstance narrowing)
fb2a984b wait for the first cron boundary instead of running discovery at startup test_durable_scheduler.py::test_cron_discoverer_waits_for_its_first_boundary_instead_of_running_at_startup
0bb9eda8, 0a478b2a accept APScheduler's last day-of-month marker, validation still unconditional 5 cases in tests/builtin/runtime/test_cron.py
c9dd22b0, e7679469 repeated cancellation with the row's current version is idempotent tests/builtin/persistence/test_work.py replay case, tests/test_operation_routes.py::test_repeated_cancellation_of_a_running_operation_is_idempotent (200/200/409 over HTTP)
ed3accd6 declare persistence_sha256 = 054731df… for the cancellation change scripts/upgrade_release_gate.py → upgrade-policy: upgrade-required
bfbd2e95 pin griffe<2 for the website generator (fumapy imports a symbol griffe 2 moved to griffelib) check-website green on run 42647706651
5a8dd237 pin the standalone Connector type checker to ty==0.0.56 (the locked version) opendal-package
a25b6adf3 name the ACP SDK in Harbor's runner venv and log the version it resolves e2e/bub/tests/test_harbor_agent.py::test_runner_venv_is_pinned_to_the_sdk_the_acp_server_names

Red checks on the previous head, and where they come from

tests (3.12, unit) — tests/builtin/inference/test_codex_credentials.py::test_codex_workers_and_restart_observe_cli_renewal asserts process.exitcode == 0 right after process.join(10), and the spawned worker had not exited yet (assert None == 0). The test arrived on master today in #2432 (6d781dcfe); the same job passes on master's own run 42615177843, so this is the timing assumption in the new test under a loaded matrix, not a Work Ledger path. Nothing in this branch touches it.

Upgrade release gate — a consequence, not a second failure: upgrade-acceptance is gated on needs.tests.result in {success, skipped}, so the unit failure skipped it and the gate saw UPGRADE_SELECTED=true / UPGRADE_RESULT=skipped. The digest itself is consistent — scripts/upgrade_release_gate.py prints upgrade-policy: upgrade-required at this head.

harness-validation / Acceptance (sqlite) — run 42687710011, job 129376714407. All four steps ended at No ACP summary file found; the step's own agent/acp.txt is the cause:

File "/installed-agent/acp_runner.py", line 16, in <module>
    from acp import PROTOCOL_VERSION, RequestError, spawn_agent_process, text_block
ImportError: cannot import name 'text_block' from 'acp' (/opt/harbor-acp-venv/...)

acp_runner.py belongs to Harbor, and Harbor builds the venv that runs it with a bare pip install agent-client-protocol. This branch changes nothing under e2e/ before a25b6adf3 (git diff --stat 2291237a..5a8dd237 -- e2e/ is empty), and the same reusable workflow passed on master on 2026-11-07 (runs 42365885788, 42366219630) before that unpinned resolve started returning a release without the symbol. a25b6adf3 names the SDK this repository already pins for bub-acp-server==0.1.3 (agent-client-protocol==1.0.0rc2) in that venv, and prints the resolved version plus the symbols the runner imports, so a mismatch shows up in the setup log instead of as four missing summaries.

Master is red on the same workflow today as well: run 42615177843 fails Acceptance (sqlite) earlier with exec: "powercontext-e2e-container": executable file not found in $PATH, Acceptance (oceanbase) on its 50-minute step limit, and Recall effort (OceanBase) with exit code 124. That work is tracked by #2537, so the container-side outcome of a25b6adf3 is verified by this branch's next run rather than assumed here.

Threads answered in this round

  • docker/compose.distributed.yaml migrator → db-migrate plan/apply (c9934b701, plus tests/test_docker_contract.py executing the rendered script against a stub CLI)
  • tests/test_server_migration_cli.py env-file path → database.as_posix() (182106be3)
  • docker/compose.distributed.yaml generation model → the Server-prefixed variable in both worker definitions (44cc6b02e)

All of the above is at head a25b6adf3. Requesting re-review from @hidb4ai, @AlexStocks and @knqiufan; the remaining review items are answered in their threads.

… with pip

`test_bub_runtime_install_does_not_use_the_task_images_pip_index` unpacks a
single command containing "pip install". Harbor's dependency step and the
runner pin added in `a25b6adf3` both install with pip, so the unpack would
fail and take `make harness-check` with it. Assert the property the test is
about — no pip step may reach the image's configured index — across every
matching step instead.
`test_codex_workers_and_restart_observe_cli_renewal` joins each spawned
worker for ten seconds and then asserts `exitcode == 0`. Every worker has
already answered on the pipe at that point, so the assertion is about
interpreter shutdown, not about the credential behaviour under test, and the
shared matrix runner gives two cores to eight pytest workers:

- run 42687710011, `tests (3.12, unit)`:
  `AssertionError: assert None == 0 ... <SpawnProcess name='SpawnProcess-2' pid=... started>`
- run 42714396200, `tests (3.11, unit)`: the same assertion, same worker
  state, after the expected `[(".../codex/responses", "***", "renewed-account")]`
  reply had already been asserted.

The test arrived on master today in oceanbase#2432 (`6d781dcfe`). Wait up to a minute
and report the worker that did not exit, so a scheduling delay no longer
reads as a product failure while a worker that never exits still fails here.

No production code changes.
…can import

The runner pin introduced in a25b6ad repeated the ACP server's requirement
(agent-client-protocol==1.0.0rc2), but the runner venv is a separate
interpreter from Bub's plugin environment, and the reorganized 1.x SDK no
longer exports what Harbor's acp_runner.py imports. The pin therefore
installed a version that dies on the runner's own import line:

  ImportError: cannot import name 'text_block' from 'acp'
    (/opt/harbor-acp-venv/lib/python3.12/site-packages/acp/__init__.py)

which leaves no acp-summary.json and fails the trial with "No ACP summary
file found" for every step (run 42720373883, Acceptance (sqlite)/(oceanbase);
the evidence artifact shows powercontext-step-failed in all four steps).

Fix, verified against the published wheels:

- 0.12.1 exports text_block, spawn_agent_process, RequestError and
  PROTOCOL_VERSION; 1.0.0rc2 and 1.0.0 export none of the helpers. Pin the
  runner venv to 0.12.1 (ACP_RUNNER_SDK_REQUIREMENT) and keep the server's
  rc2 pin in the Bub plugin environment where it belongs.
- The probe now exits non-zero when a runner symbol is missing instead of
  printing it, so a mismatched install fails the setup log rather than
  every step's acp.txt.

e2e/bub: 306 passed (uv run pytest e2e/bub/tests, harbor 0.16.1, locally).

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 9c43653957483ede58223867e81472d49823a104. One P1 remains: Profile and Experience still publish after contribution is revoked, without ownership records. Please address the remaining paths in that existing thread before merging.

The six other reported counterexamples no longer reproduce: internal maintenance, slow discovery, the initial Profile cron boundary, legacy last cron syntax, cancellation replay, and Windows env-file path serialization. Memory now carries the configured background identity.

Validation used SQLite runtime/domain-policy probes and ASGI HTTP routes; the Memory probe deferred audit writes around the Worker transaction for SQLite. This does not establish distributed OceanBase or native Windows acceptance. CI is still incomplete.

@jasondeng1997

Copy link
Copy Markdown
Contributor Author

fix it done

@Zxf-xufeng Zxf-xufeng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 9c43653957483ede58223867e81472d49823a104. The two inline findings below are new; I checked the existing review threads and have not repeated previously reported issues. Please address the normal-progress lane blockage and the missing membership compatibility gate before merging.

Validation: 38 related existing tests passed, and two focused reproductions exposed these failures using the actual ledger, handlers/runtime composition, and SQLite. The reproductions used locally available dependencies because downloading the locked environment failed; they do not establish OceanBase multiprocess acceptance.


if payload.cursor_generation == 0:
return generation == 1 and payload.after < sequence < payload.through
return generation == payload.cursor_generation and payload.after < sequence < payload.through

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Retire superseded windows after normal cursor generation advances

SourceCursorRepository.save() increments generation on every successful cursor update, not only on resets. Once the cursor exists, this equality therefore rejects an ordinary partial advance. For example, start at (sequence=1, generation=1), enqueue a window through source 2, then capture source 3 and take a discovery snapshot (after=1, through=3, cursor_generation=1) while the earlier window is pending. If the earlier window commits before that snapshot is enqueued, the cursor becomes (2, 2). The snapshot is then failed with non-retryable cursor_changed instead of retiring as superseded. Its failed lane head blocks subsequent work indefinitely.

A real Experience handler/worker reproduction with the same window limit for both snapshots produced failed / cursor_changed, followed by a queued remainder and zero claims. No timeout or window reduction is involved. Please distinguish normal cursor advancement from resets and retire the superseded snapshot without blocking the lane; add coverage after the first cursor generation, since the special 0 -> 1 case does not cover this race.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8bc8aff23, on head 8099c4c9c. You are right that the equality only ever matched the 0 -> 1 creation: SourceCursorRepository.save() bumps generation on every successful save, so an ordinary partial advance leaves the snapshot's cursor_generation=1 behind a persisted (2, 2).

_is_superseded_window (work_handlers.py:884) now decides on forward movement alone:

return payload.after < sequence < payload.through

A sequence strictly inside the window is by definition a racing commit, whatever the generation says. A reset leaves the sequence at or before the snapshot's prefix, so it never satisfies the predicate — it falls through to _require_exact_cursor and stays a terminal cursor_changed. The docstring states that boundary explicitly so the next change does not re-add the generation comparison.

Coverage after the first cursor generation, following your sequence exactly — tests/e2e/test_durable_experience_work.py::test_snapshot_taken_after_the_first_generation_retires_when_a_racing_window_advances:

  1. the first window commits, so the cursor row exists at (1, 1);
  2. the window through source 2 is enqueued and left pending;
  3. source 3 arrives, and the snapshot is taken as (after=1, through=3, cursor_generation=1);
  4. the pending window commits, the cursor becomes (2, 2);
  5. the snapshot is enqueued and claimed.

Before this change the snapshot's Work ended FAILED / cursor_changed and blocked its lane head. It now ends SUCCEEDED / superseded, and the lane keeps moving: the next discovery snapshot processes the remainder with candidate_count == 1 and the cursor reaches 3, so nothing is stuck behind the retired row.

worker_id=f"{membership.instance_id}:worker",
handlers=_work_handlers(config, contexts, security=opened_security),
config=config.worker,
authorizer=_work_authorizer(opened_security),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Connect membership compatibility to the worker claim gate

The production DurableWorker is constructed without claim_readiness, so RuntimeMembership.readiness() is never consulted before claiming work. When live nodes advertise different behavior_revision values, membership correctly reports misconfigured, but the worker still reports ready and executes queued work. This leaves the advertised compatibility check ineffective during an incompatible rollout.

I reproduced this through _open_work_background with two live memberships advertising revisions a and b: membership readiness was misconfigured, worker readiness was ready, and an enqueued maintenance task reached succeeded. Please connect the membership compatibility check to the worker claim gate and ensure the failing check is reflected in readiness. Add a composition-level test proving an incompatible worker leaves queued work unclaimed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6a889ff09, on head 8099c4c9c. _open_work_background() now passes claim_readiness=membership.readiness to DurableWorker (composition.py:1562), wiring the gate the worker already applies in run_once() and in its own readiness() to the membership composition actually started.

Your reproduction now lands the other way. test_membership_gate_leaves_queued_work_unclaimed (tests/builtin/runtime/test_work_publication_security.py) starts a compatible and an incompatible member against one ledger, enqueues Experience work, and asserts:

  • membership.readiness() == "misconfigured",
  • worker.readiness() == "misconfigured" (previously ready),
  • worker.run_once() == 0, with the queued row still left for later rather than executed.

Both members then stop heartbeating, their rows expire with the TTL, and the same worker instance claims and processes that row — so the gate reopens on its own and nothing was failed while it was closed. test_work_composition.py additionally asserts the composed worker's claim_readiness is bound to the started membership, which is what would have caught the missing wire.

… advance

`_is_superseded_window` required the snapshot's cursor generation to match the
persisted one before retiring, so it only recognised the 0 -> 1 row-creation
case. Every cursor save both advances the sequence and increments the
generation, so an ordinary partial commit by a racing window moved the sequence
into the window *and* bumped the generation. The snapshot then fell through to
`_require_exact_cursor`, which raised a terminal `cursor_changed`; the failed
lane head kept the logical key occupied, discovery stopped re-enqueueing that
lane, and the backlog never drained.

Retire on forward movement alone: `after < sequence < through` is exactly the
signature of a racing commit inside the window, whatever the generation says. A
genuine reset leaves the sequence at or before the snapshot's prefix, which
still falls through to the exact-cursor check and stays terminal.

`tests/e2e/test_durable_experience_work.py` covers the reported sequence: cursor
(1, 1) -> enqueue a window through 2 -> capture source three -> snapshot
(after=1, through=3, cursor_generation=1) -> racing window commits to (2, 2).
The snapshot now retires as `superseded`, the remainder covers source three only,
and the lane drains to 3.

Addresses the P1 review comment on superseded windows (discussion r4533724573).
… Profile handlers

The ledger worker authorized the claimed Scope once, before the handler ran, and
the fenced commit then wrote Candidates and moved the Profile Head under that
entry-time decision. The Family worker does not work that way: `incubate_experience`
and the Profile flush both recheck contribution *inside* the publishing
transaction and record the background principal as the owner of what they write,
precisely because generation is long enough for a grant to be revoked in the
meantime. The ledger handlers published with no such recheck, so a Contributor
revoked mid-generation kept the Candidates and the ownership attestation of its
in-flight window — and the Candidates landed with no owner row at all.

Mirror the Family wiring in `work_handlers.py`:

* `BackgroundSecurity` gains the publication hooks the opened worker security
  already exposes — `authorize_transaction`, `experience_commit`,
  `authorize_profile`, `authorize_profile_commit`, `profile_commit`.
* `ExperienceWorkHandler` runs `authorize_transaction` as the first statement of
  its completion closure and passes every written Candidate to
  `experience_commit`, so the denial rolls back the Candidates, the recurrence
  ledger rows and the cursor advance together.
* `ProfileWorkHandler` passes `authorize_profile` as the snapshot hook and
  `authorize_profile_commit` / `profile_commit` as the commit and owner hooks of
  `commit_flush`, so the Head cannot move under a revoked grant.
* A denial reaches the commit closure as `PermissionError` and is mapped to a
  non-retryable `WorkExecutionError` (`experience_publication_denied` /
  `profile_write_denied`) instead of being retried against a grant that is not
  coming back.

`composition.py` passes the opened security to both handlers, as it already did
for Memory. `tests/builtin/runtime/test_work_publication_security.py` revokes the
grant inside the pipeline's `incubate`, then asserts the window's Candidates and
cursor are untouched and the attempt is not retryable, plus the Profile hook
wiring and denial mapping.

Addresses the P1 review comments on Profile/Experience publication authorization
and Candidate ownership (discussions r4523140452, r4533566667).
…patible

`RuntimeMembership.readiness()` already reports `misconfigured` when a live
member advertises a different behavior revision (or an overlapping schema /
payload range that cannot be served), but the distributed worker never asked it:
it claimed and executed queued work against handlers whose wire format a peer
had just moved away from.

Pass the membership's readiness check to `DurableWorker` as `claim_readiness`,
the gate the worker already applies at both `run_once` and `readiness`. An
incompatible rollout now stops claiming instead of executing, and the gate
reopens by itself once the incompatible member's row expires — the queued work is
untouched, not failed.

`test_membership_gate_leaves_queued_work_unclaimed` starts a compatible and an
incompatible member against one ledger, asserts both the membership and the
worker report `misconfigured` and `run_once` claims nothing while the peer is
live, then lets the TTL expire and asserts the same worker drains the queue.

Addresses the P2 review comment on the membership compatibility gate
(discussion r4533724580).
Conflicts came from oceanbase#2537, which fixed the same Harbor runner ACP SDK mismatch
independently of this branch:

* `e2e/bub/src/powercontext_e2e/harbor_agent.py`, `e2e/bub/tests/`: take
  up/master. Its constraint-file pin (`agent-client-protocol==0.10.0` through
  `PIP_CONSTRAINT`) replaces this branch's explicit runner-venv install plus
  symbol probe, so the probe, its test, and the README paragraph describing it
  go away with the code they documented.
* The ACP server pin, the pip-index requirement, and the remaining harness/CI
  hunks merged as-is.
…ublication hooks

`make workflow-actions-check` runs `ty check` over the tests as well as the
package, and extending `BackgroundSecurity` with the publication hooks made the
Memory handler's parameter demand five methods it never calls: preparation only
forwards `principal`, `access` and `context` onto the Atomic Memory execution
context. `tests/builtin/runtime/test_memory_work.py` failed the check for
exactly that reason.

Split the protocol instead of widening every fake:

* `BackgroundIdentity` carries the three attributes a preparing handler needs;
  `BackgroundSecurity` extends it with the transaction and ownership hooks the
  Experience and Profile publications use.
* `MemoryWorkHandler` takes a `BackgroundIdentity`; the two publishing handlers
  keep the full `BackgroundSecurity`.

The test fixtures in `test_work_publication_security.py` also needed narrowing
for the checker: the stand-ins for the Profile service and the Work claim are
cast at the call site, the denial fake implements the whole protocol, the flush
result uses a status the model declares, and the candidate ids are narrowed
before they are turned into a tuple. `test_work_composition.py` reads the bound
method through `getattr` so the assertion type-checks.

`uv run ty check`, `ruff check`, `ruff format --check` and the four affected
test modules all pass locally.
… plugin

Merging master brought oceanbase#2495, which verifies the installed plugin's declared
Memory protocol on every `setup dsh` and fails the command when the
`powercontext-dsh` bundle does not declare `atomic-artifact-v1`. The runtime
include-tree fixture impersonates that bundle without the declaration, so the
expected-success phase of "setup validates native include trees" exited 1 with
`plugin_memory_protocol: unverified` before it could assert anything — the
`dsh-package` job fails the same way on the merged tree (master cannot see this
yet because its own job stops at an earlier step).

Declare the contract on the fixture, as the real plugin's package.json does. The
fixture keeps its third-party shape otherwise; oceanbase#2495's policy — setup and doctor
fail on an unverified or incompatible PowerContext plugin — is unchanged and
still covered by the unit tests in `tests/test_dsh_cli.py`.

Verified locally: the failing scenario passes, and the full
`node --test --test-concurrency=1 setup.test.mjs runtime.test.mjs` suite is
19/19 green.
dsh: { bundle: { patch: './cordis.patch.yml' } },
// The fixture impersonates the PowerContext DSH plugin, so it must declare the
// Memory contract the CLI verifies on every setup (atomic-artifact-v1).
powercontext: { memoryProtocol: 'atomic-artifact-v1' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 本次提交只补了 JS fixture,同根因的 Python fixture 仍使 dsh-package 第 11 步在本 head 红

本 head 上 dsh-package 仍是 failure:run 42991410345 / job 130469094641,步骤 11 Verify DSH setup with customized profiles 失败,步骤 12 Verify DSH guidance adapter requests and results 被 skip。

失败用例与断言(tests/test_dsh_transport.py:1072):

tests/test_dsh_transport.py::test_explicit_web_doctor_does_not_read_desktop_environment_profile
E  assert 1 == 0
E  "plugin_memory_protocol": {"ok": false, "status": "degraded",
E                            "checks": {"memory_protocol": "unverified"}}

成因(源码级判断):该用例的 profile 由 dsh_profile(tests/test_dsh_transport.py:36-55)建成 {"dsh": {"profile": {"bundles": []}}},没有任何 PowerContext bundle;_run_dsh 被打桩返回 id: powercontext-dsh,于是 plugin 检查报 registration: present,但 read_dsh_settings(..., require_installed=True) 组合出的 settings 里没有 memoryProtocol,installed_memory_protocol(None, "dsh") 落到 DiagnosticStatus.DEGRADED(src/powercontext/cli/memory_protocol.py:36-52),doctor 退出码 1。同一形态的第二处是 plugin_source(tests/test_dsh_transport.py:133-148):它写的 package.json 只有 name 与 dsh.bundle.patch,同样没有 powercontext.memoryProtocol。

即:本次提交为本行 fixture 补上了 memoryProtocol 声明,Python 侧同形 fixture 未补,#2495 的新判定在这条路径上仍然把宿主判成 degraded。

建议:给 plugin_source 与需要 doctor 成功断言的那些 dsh_profile 用例的 package.json 加上 "powercontext": {"memoryProtocol": "atomic-artifact-v1"}(与真实插件 integrations/dsh/plugins/powercontext/package.json 一致),或把该用例的期望改为显式的 degraded 断言。两种改法都会改变断言语义,请选择与 doctor 契约一致的那种,并在改后重跑 dsh-package。

归因边界(避免误读):master 上同一 job 在第 6 步就红(tests/status-http.spec.ts 断言 expected 'skipped' to be 'appended'),第 11 步在 master 侧被 skip,因此本条的对照只有本 head 的 95e0a357,不主张 master 上必然同样失败。

@jasondeng1997 jasondeng1997 Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, and agreed on the root cause — the Python-side setup fixtures needed the same powercontext.memoryProtocol declaration as the real plugin, not a weakened doctor expectation. That is now in the branch: 05a50973e (#2569, already on this line before the merge) adds {"powercontext": {"memoryProtocol": "atomic-artifact-v1"}} to the synthetic plugin_source manifest in tests/test_dsh_transport.py:1071-1074, matching integrations/dsh/plugins/powercontext/package.json; 230efe1a (#2580, merged in 8d9d107a4) adds the corresponding JS fixture declaration in setup.test.mjs and asserts plugin_memory_protocol.status == "ok". On the current head c08c8c630 the previously failing test_explicit_web_doctor_does_not_read_desktop_environment_profile therefore asserts a healthy Memory protocol instead of a degraded one, and no assertion was changed to accept degraded.

Your baseline 95e0a357 had zero memoryProtocol occurrences in that Python test file; the current head has the declaration plus the stronger status assertion. dsh-package step 11 is being re-run on the re-triggered CI for c08c8c630; I will report the job result here once it completes.

Update: the final head is db2c2cf62 — it only pins an explicit anchor for the new desktop/VALIDATION.md section; CI is running on that head.

…ture

The `dsh-package` job reached "Verify DSH setup with customized profiles" for
the first time on this branch and failed one case:

    tests/test_dsh_transport.py::
        test_explicit_web_doctor_does_not_read_desktop_environment_profile

`doctor dsh --profile web` verifies the installed plugin's declared Memory
protocol (oceanbase#2495). The fixture's Web profile was an empty `bundles: []`, so
`read_dsh_settings(..., require_installed=True)` could not resolve a plugin,
the compatibility diagnostic degraded to `unverified`, and the command exited
1 before the assertion about profile selection ran. Master never observed it
because its own `dsh-package` run stopped at an earlier JS step; current master
(oceanbase#2306) fixes the same fixture by installing a bundle that declares the
contract.

Give the Web profile a resolvable `powercontext-dsh` bundle whose manifest
declares `atomic-artifact-v1`, then assert the diagnostic reports that exact
protocol. The Desktop profile keeps its empty bundle list and its own baseUrl,
so the original point of the test still holds: if doctor read the Desktop
profile selected by the DSH_PROFILE environment variable, the check would
degrade rather than pass. Upstream's policy (setup/doctor fail on an unverified
or incompatible plugin) is unchanged.

Verified locally with the real DSH runtime and config boot:
`uv run pytest tests/test_dsh_transport.py --require-dsh-runtime
--require-dsh-config-runtime` -> 109 passed. `ruff check`, `ruff format
--check` and `ty check` all pass.
Master advanced 27 commits after the previous merge and opened five conflicts.
None of them touch the Work Ledger runtime: work_handlers.py, composition.py,
worker.py, membership.py and the ledger tests are unchanged upstream.

* `tests/test_dsh_transport.py` and
  `integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs`: take
  up/master. oceanbase#2306 fixed the same two synthetic-plugin fixtures independently
  (declaring `atomic-artifact-v1` on the installed plugin so `setup`/`doctor`
  can verify the Memory contract). Keeping our equivalent patches would duplicate
  the fix and our `_run_dsh` stub references the doctor path master refactored.
* `tests/test_desktop_contract.py`: keep both added tests — our bundled-contract
  qualification check and master's local-setup catalog digest check.
* `desktop/VALIDATION.md`: keep both qualification records — the Work Ledger
  7d716b6/2cb8da31 sections and master's installed connection journey section.
* `release/upgrade-support.json`: concatenate both rationale additions (the
  v1_5_0_005 Work Ledger tables and master's FTS helper v2 / seekdb identity
  notes), and recompute `schema_review.persistence_sha256` as
  `d81b7d0e...` — the merge brings master's
  `builtin/persistence/oceanbase/profile.py` into the digest's managed inputs,
  so neither side's stored digest matches the merged tree anymore.

Local verification on the merged tree: dsh transport 109 passed (with real DSH
runtime and config boot), desktop contract + upgrade gate + release version
144 passed / 6 skipped, Work Ledger core 27 passed, `load_policy` passes with
the recomputed digest, and `ruff check`, `ruff format --check`, `ty check` all
pass.
@AlexStocks

Copy link
Copy Markdown
Contributor

@jasondeng1997 the github ci process failed. please fix this problem.

`tests (3.12, unit)` on run 43170565715 failed one case out of 8998:

    test_codex_workers_and_restart_observe_cli_renewal
    AssertionError: worker 1 exited with None

Both workers had already sent the expected renewed-credential reply and both
`poll(30)` / `recv()` assertions had passed. The failure is purely about CPython
shutting the spawned interpreter down: `_worker` returns normally, and shutdown
joins whatever background threads `open_builtin_runtime` left behind while
competing with seven other pytest workers on a two-core CI shard. This branch
already widened the join from 10s to 60s (3eaccdd) after runs 42687710011 and
42714396200 failed the same way; 60s still was not enough. Every other red job
on that run is a consequence of this one — the five cancelled test shards, the
`Upgrade release gate` (`upgrade-acceptance` is skipped when `tests` is not
success/skipped, so the gate sees `UPGRADE_RESULT=skipped` with upgrade
selected), and the `CI result` rollup.

Make the worker leave with an explicit code. The reply is already written to
the pipe, so `_worker` calls `os._exit(0)` after closing its end, skipping only
interpreter shutdown — a process whose entire output has been sent has nothing
left to flush. A crash prints its traceback and exits 1, which the parent still
reports through `_require_clean_exit`, and also shows up as a missing reply on
the pipe. The 60s join remains as a deadlock backstop, no longer as a bet on
runner scheduling.

Measured with an instrumented probe that injects a 120s non-daemon thread into
the worker: normal shutdown is still alive after a 5s join with
`exitcode=None` (5.19s elapsed), while `os._exit` finishes in 0.19s with
`exitcode=0`. The test module is 6/6 green, the target case passed three
consecutive runs (~13.2s each), and `ruff check`, `ruff format --check`,
`ty check` and the prek hooks all pass.
…h acceptance

Run 43194760158 failed `tests (3.13, e2e)` with one case out of 1522:

    test_model_ingress_authentication_with_real_core_and_provider[enforcing-responses]
    httpx.RemoteProtocolError: Server disconnected without sending a response.
    <HTTP11Connection [..., CLOSED, Request Count: 7]>

The four cancelled test shards, the `Upgrade release gate`
(`needs.tests.result` is `failure`, so `upgrade-acceptance` is skipped while
upgrade is still selected), and the `CI result` rollup are all consequences of
that single failure. The test file is byte-identical to up/master, the same
shard passed on runs 42991410345 and 42962504630, and the other seven
route/mode combinations passed in this very run — so this is a runner-level
race, not a Work Ledger regression.

`running_gateway` hands every case an `httpx.Client` with the default pool.
The model-ingress case reuses one HTTP/1.1 connection for `/healthz` and its
eleven rejection probes. When axum closes an idle connection between two
probes, the client can pick that half-closed connection before it observes the
FIN: it writes the request successfully, then reads EOF while waiting for the
response. httpcore's 5s `keepalive_expiry` and the server's own keep-alive
timer drift apart easily on a two-core shard shared by eight pytest workers,
which is why the failure moves between runs and parameter combinations.

Do not retain idle connections in that client
(`httpx.Limits(max_keepalive_connections=0)`): each probe opens a fresh socket,
so there is no stale connection to read EOF from. This changes no request
header, no response assertion and no gateway code; the raw-socket framing
probe later in the case is unaffected.

Measured with an instrumented HTTP/1.1 server that answers six requests per
connection and then closes silently:

    pooled (current fixture)     10 probes -> 401 x 6, RemoteProtocolError
    no idle keep-alive (fixed)   10 probes -> 401 x 10

The seventh request is exactly the `Request Count: 7` from the CI traceback.
A separate connection-count check gives 1 socket / 4 requests for the default
pool versus 4 / 4 with the new limits. Built `pc-gateway` locally (Rust 1.99.1)
and ran the case serially: 8/8 passed; the whole module is 33 passed with one
macOS-only failure (`127.23.45.67` cannot be bound on darwin; Linux CI binds
it). `ruff check`, `ruff format --check`, `ty check` and the prek hooks pass.

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed eb6a9dc788d643e9a0bcebaded89f4319254243d. One P1 remains: Profile publication still bypasses revoked Scope contribution. The production-provider replay succeeds after revocation for initial creation, an owned Profile update, and a review-required Candidate. Please add the missing transactional contribution check while retaining the new Head and ownership hooks.

Experience revocation/ownership, cursor-progress retirement, and the membership claim gate now pass the affected checks. Validation used SQLite runtime/domain-policy probes, with audit writes deferred around the publication transaction; it does not establish OceanBase multiprocess acceptance.

Separately, required CI remains red: the Python 3.13 e2e job fails in test_load_real_background_processes_and_preserves_disabled_state[True] because a Source process did not become ready. Upgrade acceptance was consequently skipped. I have not established that this startup failure was introduced by this PR.

…g it

The detached manager [True] leg of test_source_deployment allocated both
ephemeral ports at scenario setup via bind(0) and released them immediately.
The Server binds its port right away, but the manager does not bind until after
the Server readiness poll (up to 40s). Under pytest -n auto that leaves a long
window in which a sibling xdist worker can claim the same port; uvicorn then
exits before writing 'ready' to the startup pipe, and _spawn reports
'startup-failed: Source process did not become ready'.

Reserve the manager port at the last possible moment instead, retry a bounded
number of times when the startup handshake races, and include the manager's
process.log in the final assertion so a genuine startup defect is visible in CI
output rather than hidden behind 'inspect process.log'.
The 13 commits landed on master after the PR base add Task Stop Reason contracts and processing diagnostics. Resolve the four conflicting files by keeping both lineages:

- openapi/powercontext.yaml: union of the Work Ledger operation endpoints and the Task Stop Reason schemas (digest 54c69ae3f8aa).
- desktop generated operations.json / operations.ts: regenerated from the merged contract with pnpm generate; api.d.ts and wire.rs verified with generate --check.
- compatibility.json: retain all 70 Work Ledger profiles plus master's two Task Stop Reason profiles.
- profile/service.py: keep the prepare/commit split and carry master's attach_diagnostics on invalid Markdown, so BUSINESS_VALIDATION / INVALID_OUTPUT diagnostics survive the merge.

Python API models and JS operations were regenerated and pass make contract-test's generation checks; release/upgrade-support.json digest remains d81b7d0e.
…tract

Merging master 1cb0e75 into the Work Ledger line produced a contract that carries both lineages, so neither side's profile describes the bundled digest 54c69ae3f8aa. Build powercontext-1.4.1.dev705+g8d9d107a4 from the clean merge commit 8d9d107, rebuild the native probe against the new catalog, and run all four product modes through the ordinary ConnectionManager: anonymous loopback, static Bearer, HTTPS with explicit CA/base path, and enforced Provider. All four pass with thirteen bundled operations; desktop/VALIDATION.md records the digests, timings and the loopback-only scope of the evidence.
…on fence

ProfileWorkHandler.commit() only passed authorize_profile_commit and profile_commit to commit_flush(). With the real WorkerSecurity the former is a no-op on first publication (no current Profile) and only checks Artifact write for an existing Head, which the owner keeps after scope.contribute is revoked. A grant revoked during generation therefore still published a Profile, advanced the cursor and succeeded the Work item.

Call authorize_transaction(connection, claim.scope_id) first inside the Work completion transaction, mirroring ExperienceWorkHandler, so the contribution recheck gates every Profile, Candidate and cursor write. PermissionError still maps to profile_write_denied (category=authorization, retryable=False) and no durable write leaves the transaction.

Reproduced before the fix with a real SQLite + RelationalAccessRepository + WorkerSecuritySpec probe: Work SUCCEEDED, revision 1 and cursor 1 committed after revocation. After the fix the same probe fails with authorization/profile_write_denied, cursor None and latest Profile None. Also add a recording-fake regression where only the transaction recheck denies, so the earlier always-raising authorize_profile_commit fake can no longer mask the missing call. 5 publication-security tests and 27 related runtime tests pass.
Give the new qualification section an explicit anchor so the compatibility.json evidence link resolves independently of GitHub's heading slug (the heading contains a slash).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.6.0 v1.6.0 release tracking

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants