From fb026f7ec4c3f7773004215ccbbe9903854587ba Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:16:21 +0800 Subject: [PATCH 01/11] test: gate SQLite lifecycle cases on qualified runtime Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../deferred_hard_lease_lifecycle.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts index 73e1f4c9e5..ab75013e25 100644 --- a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts +++ b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts @@ -7,6 +7,7 @@ import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; @@ -21,6 +22,8 @@ const GOAL = "synthetic-goal"; const TODO = "todo_task"; const OWNER = "agent-a"; const AGENTS = [OWNER, "agent-b"]; +const sqliteSkip = sqliteRuntimeIdentity().sqlite_authority_qualified + ? false : "requires the qualified SQLite runtime"; async function seeded(provider: "file" | "sqlite", overrides: JsonObject = {}, lease?: JsonObject) { const root = await mkdtemp(join(tmpdir(), `loopx-deferred-${provider}-`)); @@ -72,7 +75,8 @@ function oldLease(status: "active" | "released", expires_at: string): JsonObject } for (const provider of ["file", "sqlite"] as const) { - test(`${provider}: deferred resume is one CAS transition and never grants execution`, async () => { + test(`${provider}: deferred resume is one CAS transition and never grants execution`, + {skip: provider === "sqlite" && sqliteSkip}, async () => { const store = await seeded(provider); const before = await read(store); assert.equal(leaseOwnerRejection({status: "deferred", claimed_by: OWNER, excluded_agents: []}, OWNER, AGENTS), @@ -99,7 +103,8 @@ for (const provider of ["file", "sqlite"] as const) { "coordination_operation_identity_mismatch"); }); - test(`${provider}: expired lease is retired atomically, while live execution and foreign edits fail closed`, async () => { + test(`${provider}: expired lease is retired atomically, while live execution and foreign edits fail closed`, + {skip: provider === "sqlite" && sqliteSkip}, async () => { const expired = await seeded(provider, {}, oldLease("active", "2026-09-05T22:30:00Z")); const resumed = await executeCoordinationTodoUpdate(expired, resume("retire-expired")); assert.equal(resumed.status, "applied", JSON.stringify(resumed)); @@ -123,7 +128,8 @@ for (const provider of ["file", "sqlite"] as const) { } }); - test(`${provider}: an unclaimed deferred Todo stays unclaimed; excluded actors cannot reopen it`, async () => { + test(`${provider}: an unclaimed deferred Todo stays unclaimed; excluded actors cannot reopen it`, + {skip: provider === "sqlite" && sqliteSkip}, async () => { const unclaimed = await seeded(provider, {claimed_by: null}); const applied = await executeCoordinationTodoUpdate(unclaimed, resume("unclaimed-resume")); assert.equal(applied.status, "applied", JSON.stringify(applied)); @@ -138,7 +144,8 @@ for (const provider of ["file", "sqlite"] as const) { assert.deepEqual(await read(excluded), before); }); - test(`${provider}: deferred supersede closes one Todo and retires expired lease lineage`, async () => { + test(`${provider}: deferred supersede closes one Todo and retires expired lease lineage`, + {skip: provider === "sqlite" && sqliteSkip}, async () => { const store = await seeded(provider, {}, oldLease("active", "2026-09-05T22:30:00Z")); const input = supersede("supersede-once"); const before = await read(store); From 9b64bfe545ca33fcbfcf2fb7c7791904d7651237 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:38:56 +0800 Subject: [PATCH 02/11] test: require SQLite qualification in the provider CI lane Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .github/workflows/python-tests.yml | 2 ++ tests/control_plane_ts/sqlite_runtime_admission.test.ts | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 0e9d979fa7..8680847c5c 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -123,6 +123,8 @@ jobs: node-version: "22.22.3" - name: Qualify the TypeScript Effect core + env: + LOOPX_TEST_REQUIRE_SQLITE_QUALIFIED: "1" run: | npm ci --ignore-scripts npm run typecheck:control-plane diff --git a/tests/control_plane_ts/sqlite_runtime_admission.test.ts b/tests/control_plane_ts/sqlite_runtime_admission.test.ts index 5ed29a41ca..c137a876e3 100644 --- a/tests/control_plane_ts/sqlite_runtime_admission.test.ts +++ b/tests/control_plane_ts/sqlite_runtime_admission.test.ts @@ -73,12 +73,16 @@ test("a direct CLI run is told to rerun on a qualified Node instead", async () = assert.doesNotMatch(reason, /restart-runtime/); }); -test("runtime identity publishes the serving Node and SQLite pair", () => { +test("runtime identity publishes the serving pair and enforces required qualification", () => { const identity = sqliteRuntimeIdentity(); assert.equal(identity.schema_version, "loopx_sqlite_runtime_identity_v0"); assert.equal(identity.node_version, process.version); assert.equal(typeof identity.sqlite_available, "boolean"); assert.equal(typeof identity.sqlite_authority_qualified, "boolean"); + if (process.env.LOOPX_TEST_REQUIRE_SQLITE_QUALIFIED === "1") { + assert.equal(identity.sqlite_authority_qualified, true, + `qualified SQLite test lane cannot skip provider cases: ${identity.node_version}/${identity.sqlite_version}`); + } if (!identity.sqlite_available) { assert.equal(identity.sqlite_version, null); assert.equal(identity.sqlite_authority_qualified, false); From 83a43b123aca0e813a44ca10e2143837ce0c2053 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:09:26 +0800 Subject: [PATCH 03/11] fix: raise supported Node floor to WAL-fixed SQLite runtime Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .github/workflows/python-tests.yml | 25 ++++++++----------- examples/github-actions-runtime-smoke.py | 8 +++--- .../coordination/sqlite_runtime.ts | 6 ++--- loopx/control_plane/effect_runtime.py | 2 +- .../scheduler/heartbeat_followup_cli.ts | 7 ++++++ loopx/entrypoint.py | 2 +- package-lock.json | 2 +- package.json | 2 +- scripts/loopx | 2 +- scripts/loopx.ps1 | 2 +- .../test_start_goal_compact_projection.py | 2 +- .../test_turn_journal_characterization.py | 2 +- .../test_turn_journal_runtime_readiness.py | 22 ++++++++-------- .../deferred_hard_lease_lifecycle.test.ts | 11 +++----- .../goal_acceptance_authority.test.ts | 5 ++-- tests/test_cli_entrypoint.py | 2 +- tests/test_loopx_turn_journal_inspection.py | 4 +-- tests/test_python_ci_workflow.py | 10 +++++--- 18 files changed, 59 insertions(+), 57 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 8680847c5c..01d535cd85 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -215,31 +215,26 @@ jobs: needs: changes if: needs.changes.outputs.core_tests == 'true' runs-on: ubuntu-latest - # The full public-minimum suite has reached fifteen minutes on hosted runners. - # Keep a bounded margin above the measured workload without weakening coverage. - timeout-minutes: 20 + # The full suite runs under this exact Node version in kernel-static-checks. + timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: - node-version: "22.18.0" + node-version: "22.22.3" cache: npm cache-dependency-path: package-lock.json - name: Qualify the public minimum Node.js runtime + env: + LOOPX_TEST_REQUIRE_SQLITE_QUALIFIED: "1" run: | npm ci --ignore-scripts npm run typecheck:control-plane - # Default File/control-plane compatibility stays on the public minimum. - # SQLite requires a WAL-fixed embedded driver; its admission rejection - # runs here, while full SQLite suites run on the qualified runtime. - tests=() - for test in tests/control_plane_ts/*.test.ts; do - case "$test" in - */sqlite_authority_store.test.ts|*/sqlite_authority_bounded_profile.test.ts|*/sqlite_authority_migration.test.ts|*/local_authority_provider.test.ts|*/authority_provider_parity.test.ts|*/sqlite_capacity.test.ts) continue ;; - esac - tests+=("$test") - done - node --no-warnings --experimental-sqlite --experimental-strip-types --test "${tests[@]}" + # Prove the minimum itself admits SQLite and executes both provider + # lifecycle rows. The qualified core lane runs the complete suite. + node --no-warnings --experimental-sqlite --experimental-strip-types --test \ + tests/control_plane_ts/sqlite_runtime_admission.test.ts \ + tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts node-forward-compatibility: needs: changes diff --git a/examples/github-actions-runtime-smoke.py b/examples/github-actions-runtime-smoke.py index 08551de560..5ba76e54c9 100644 --- a/examples/github-actions-runtime-smoke.py +++ b/examples/github-actions-runtime-smoke.py @@ -19,7 +19,7 @@ } PRIMARY_NODE_VERSION = "24" -MINIMUM_NODE_VERSION = "22.18.0" +MINIMUM_NODE_VERSION = "22.22.3" MINIMUM_NODE_ACTION_VERSION = MINIMUM_NODE_VERSION FORWARD_NODE_VERSION = "26" # SQLite needs both synchronous statement finalization and the WAL-reset fix. @@ -88,7 +88,7 @@ def main() -> int: r"^ ([a-z][a-z0-9-]*):\n(.*?)(?=^ [a-z][a-z0-9-]*:\n|\Z)", python_workflow, re.MULTILINE | re.DOTALL, )) - for name in ("kernel-static-checks", "dashboard-acceptance", "windows-powershell"): + for name in ("kernel-static-checks", "node-minimum-compatibility", "dashboard-acceptance", "windows-powershell"): assert SQLITE_NODE_VERSION in node_version_pattern.findall(jobs[name]), name postgresql_versions = node_version_pattern.findall( workflows["postgresql-integration.yml"] @@ -102,8 +102,8 @@ def main() -> int: assert package["engines"]["node"] == f">={MINIMUM_NODE_VERSION}" print( - "github-actions-runtime-smoke ok: Node 24 primary, 22.18 minimum, " - "26 forward, 22.22.3 sqlite/authority" + "github-actions-runtime-smoke ok: Node 24 primary, " + "22.22.3 minimum and SQLite/authority reference, 26 forward" ) return 0 diff --git a/loopx/control_plane/coordination/sqlite_runtime.ts b/loopx/control_plane/coordination/sqlite_runtime.ts index 1fe79512ca..62882ea835 100644 --- a/loopx/control_plane/coordination/sqlite_runtime.ts +++ b/loopx/control_plane/coordination/sqlite_runtime.ts @@ -97,15 +97,15 @@ export function sqliteAuthorityRuntime(): {driver: typeof import("node:sqlite"); ? `This managed Effect runtime (pid ${process.pid}, Node ${identity.node_version}) is the one serving this goal; it keeps its Node until it exits. Install the qualified Node on PATH, then run \`loopx doctor --restart-runtime\` (or wait for its idle shutdown) so the next request starts a new runtime.` : "Rerun this command on a PATH whose Node is the qualified runtime."; if (!identity.sqlite_available) { - throw new AuthorityStoreProtocolError(`SQLite authority requires a runtime with node:sqlite (${identity.unavailable_reason}); use the qualified Node 22.22.3 runtime. File authority uses the public Node minimum. ${repairGuidance}`); + throw new AuthorityStoreProtocolError(`SQLite authority requires a runtime with node:sqlite (${identity.unavailable_reason}); use Node 22.22.3 or newer with a qualified embedded SQLite driver. ${repairGuidance}`); } if (identity.sqlite_authority_qualified !== true) { - throw new AuthorityStoreProtocolError(`SQLite authority runtime is not qualified (SQLite ${identity.sqlite_version}, synchronous statement finalization=${identity.synchronous_statement_finalization}); require the WAL-reset fix in SQLite 3.51.3+, 3.50.7+ or 3.44.6+ and finalized statements on close. Use the qualified Node 22.22.3 runtime; File authority uses the public Node minimum. ${repairGuidance}`); + throw new AuthorityStoreProtocolError(`SQLite authority runtime is not qualified (SQLite ${identity.sqlite_version}, synchronous statement finalization=${identity.synchronous_statement_finalization}); require the WAL-reset fix in SQLite 3.51.3+, 3.50.7+ or 3.44.6+ and finalized statements on close. Use Node 22.22.3 or newer with a qualified embedded SQLite driver. ${repairGuidance}`); } let driver: typeof import("node:sqlite"); try { driver = require("node:sqlite") as typeof import("node:sqlite"); } catch { - throw new AuthorityStoreProtocolError(`SQLite authority requires node:sqlite; use the qualified Node 22.22.3 runtime. File authority uses the public Node minimum. ${repairGuidance}`); + throw new AuthorityStoreProtocolError(`SQLite authority requires node:sqlite; use Node 22.22.3 or newer with a qualified embedded SQLite driver. ${repairGuidance}`); } const info = {node_version: identity.node_version, sqlite_version: String(identity.sqlite_version), sqlite_source_id: String(identity.sqlite_source_id), diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index fd8783e928..6b28d01bf4 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -29,7 +29,7 @@ EFFECT_RUNTIME_STARTUP_ERROR_SCHEMA_VERSION = ( "loopx_effect_runtime_startup_error_v0" ) -MINIMUM_NODE_VERSION = (22, 18, 0) +MINIMUM_NODE_VERSION = (22, 22, 3) MINIMUM_NODE_VERSION_TEXT = ".".join(str(part) for part in MINIMUM_NODE_VERSION) MAX_RESPONSE_BYTES = 2 * 1024 * 1024 MAX_REQUEST_BYTES = 2 * 1024 * 1024 diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index c8d86cfc50..9155cd322d 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -304,6 +304,13 @@ async function readRequest(): Promise { async function main(): Promise { try { + const [major, minor, patch] = process.versions.node.split(".").map(Number); + if (major < 22 || (major === 22 && (minor < 22 || (minor === 22 && patch < 3)))) { + throw new EffectRuntimeRequestError( + "native scheduler follow-up requires Node.js 22.22.3 or newer", + "node_unsupported", + ); + } const fromArgs = process.argv.length > 2; const parsed = fromArgs ? requestFromArgs(process.argv.slice(2)) diff --git a/loopx/entrypoint.py b/loopx/entrypoint.py index 43aa6e8469..d92270513c 100644 --- a/loopx/entrypoint.py +++ b/loopx/entrypoint.py @@ -47,7 +47,7 @@ def _native_scheduler_followup_argv(raw_argv: list[str]) -> list[str] | None: return None node = shutil.which("node") if node is None: - raise RuntimeError("native scheduler follow-up requires Node.js 22.18.0 or newer") + raise RuntimeError("native scheduler follow-up requires Node.js 22.22.3 or newer") entry = ( Path(__file__).resolve().parent / "control_plane" diff --git a/package-lock.json b/package-lock.json index 8b0f70ff3c..c97af53b23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "v8-to-istanbul": "^9.3.0" }, "engines": { - "node": ">=22.18.0" + "node": ">=22.22.3" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/package.json b/package.json index 755455e6ff..ba0627bedd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "type": "module", "engines": { - "node": ">=22.18.0" + "node": ">=22.22.3" }, "scripts": { "test:control-plane": "node --no-warnings --experimental-sqlite --experimental-strip-types --test tests/control_plane_ts/*.test.ts", diff --git a/scripts/loopx b/scripts/loopx index faeb5b36f3..90fb1682d5 100755 --- a/scripts/loopx +++ b/scripts/loopx @@ -56,7 +56,7 @@ if [[ "$loopx_command" == "quota" \ && "$native_scheduler_turn" == "1" ]]; then native_scheduler_entry="$repo_root/loopx/control_plane/scheduler/heartbeat_followup_cli.ts" if ! command -v node >/dev/null 2>&1; then - echo "loopx runtime error: native scheduler follow-up requires Node.js 22.18.0 or newer" >&2 + echo "loopx runtime error: native scheduler follow-up requires Node.js 22.22.3 or newer" >&2 exit 2 fi export LOOPX_RELEASE_ROOT="$repo_root" diff --git a/scripts/loopx.ps1 b/scripts/loopx.ps1 index 37b6f5c1a2..9dd30f267f 100644 --- a/scripts/loopx.ps1 +++ b/scripts/loopx.ps1 @@ -63,7 +63,7 @@ $isNativeSchedulerFollowup = ( if ($isNativeSchedulerFollowup) { $node = Get-Command node -CommandType Application -ErrorAction SilentlyContinue if ($null -eq $node) { - throw "Native scheduler follow-up requires Node.js 22.18.0 or newer" + throw "Native scheduler follow-up requires Node.js 22.22.3 or newer" } $nativeEntry = Join-Path $releaseRoot "loopx/control_plane/scheduler/heartbeat_followup_cli.ts" if (-not (Test-Path -LiteralPath $nativeEntry -PathType Leaf)) { diff --git a/tests/control_plane/test_start_goal_compact_projection.py b/tests/control_plane/test_start_goal_compact_projection.py index 8bea202e50..33ec404732 100644 --- a/tests/control_plane/test_start_goal_compact_projection.py +++ b/tests/control_plane/test_start_goal_compact_projection.py @@ -231,7 +231,7 @@ def test_cli_reports_effect_runtime_startup_failure_without_traceback( assert payload["schema_version"] == "loopx_start_goal_guided_v0" assert payload["error"] == "LoopX TypeScript control-plane runtime is unavailable" assert payload["diagnostic_code"] == diagnostic_code - assert payload["runtime_requirement"]["minimum_node_version"] == "22.18.0" + assert payload["runtime_requirement"]["minimum_node_version"] == "22.22.3" action = payload["recommended_action"] assert expected_action_fragment in action assert ("Node.js" in action) is mentions_node_installation diff --git a/tests/control_plane/test_turn_journal_characterization.py b/tests/control_plane/test_turn_journal_characterization.py index 0f711dd5bc..98233589d5 100644 --- a/tests/control_plane/test_turn_journal_characterization.py +++ b/tests/control_plane/test_turn_journal_characterization.py @@ -35,7 +35,7 @@ def _corpus() -> dict[str, object]: def _probe() -> dict[str, object]: node = shutil.which("node") if node is None: - pytest.skip("TypeScript candidate requires Node.js 22.18.0 or newer") + pytest.skip("TypeScript candidate requires Node.js 22.22.3 or newer") return run_turn_journal_probe_command( _corpus(), command=[ diff --git a/tests/control_plane/test_turn_journal_runtime_readiness.py b/tests/control_plane/test_turn_journal_runtime_readiness.py index e43f71faf3..cb0a2b0165 100644 --- a/tests/control_plane/test_turn_journal_runtime_readiness.py +++ b/tests/control_plane/test_turn_journal_runtime_readiness.py @@ -170,7 +170,7 @@ def test_runtime_source_churn_has_a_stable_readiness_diagnostic( monkeypatch.setattr( effect_runtime.subprocess, "run", - lambda *_args, **_kwargs: _Completed(stdout="v22.18.0\n"), + lambda *_args, **_kwargs: _Completed(stdout="v22.22.3\n"), ) result = effect_runtime.collect_effect_runtime_readiness() @@ -207,7 +207,7 @@ def test_missing_node_blocks_the_typescript_control_plane_and_is_actionable( assert result["ready"] is False assert result["default_cli_blocking"] is True assert result["required_for"] == ["control_plane"] - assert "Node.js 22.18.0 or newer" in str(result["recommended_action"]) + assert "Node.js 22.22.3 or newer" in str(result["recommended_action"]) def test_missing_node_request_raises_startup_diagnostic( @@ -221,23 +221,25 @@ def test_missing_node_request_raises_startup_diagnostic( effect_runtime.effect_runtime_result("runtime.ping", {}) assert error.value.diagnostic_code == "node_unavailable" - assert "Node.js 22.18.0 or newer" in str(error.value) + assert "Node.js 22.22.3 or newer" in str(error.value) +@pytest.mark.parametrize("version", ["20.19.5", "22.18.0", "22.22.2"]) def test_old_node_is_reported_without_running_semantic_probe( + version: str, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(effect_runtime.shutil, "which", lambda _name: "node") monkeypatch.setattr( effect_runtime.subprocess, "run", - lambda *_args, **_kwargs: _Completed(stdout="v20.19.5\n"), + lambda *_args, **_kwargs: _Completed(stdout=f"v{version}\n"), ) result = effect_runtime.collect_effect_runtime_readiness(deep=True) assert result["status"] == "unsupported" - assert result["detected_node_version"] == "20.19.5" + assert result["detected_node_version"] == version assert result["semantic_probe"] == "not_run" @@ -250,7 +252,7 @@ def test_current_node_standard_probe_does_not_execute_rule( monkeypatch.setattr( effect_runtime.subprocess, "run", - lambda *_args, **_kwargs: _Completed(stdout="v22.18.0\n"), + lambda *_args, **_kwargs: _Completed(stdout="v22.22.3\n"), ) result = effect_runtime.collect_effect_runtime_readiness() @@ -311,7 +313,7 @@ def test_deep_probe_failure_is_public_safe_and_actionable( monkeypatch.setattr( effect_runtime.subprocess, "run", - lambda *_args, **_kwargs: _Completed(stdout="v22.18.0\n"), + lambda *_args, **_kwargs: _Completed(stdout="v22.22.3\n"), ) def failed(*_args: object, **_kwargs: object) -> dict[str, object]: @@ -369,7 +371,7 @@ def test_missing_required_runtime_fails_doctor_health( "status": "ready", "required_for": ["control_plane"], "default_cli_blocking": True, - "minimum_node_version": "22.18.0", + "minimum_node_version": "22.22.3", "detected_node_version": "24.1.0", "semantic_probe": "not_requested", "recommended_action": None, @@ -379,7 +381,7 @@ def test_missing_required_runtime_fails_doctor_health( "ready": False, "status": "missing", "detected_node_version": None, - "recommended_action": "Install Node.js 22.18.0 or newer.", + "recommended_action": "Install Node.js 22.22.3 or newer.", } monkeypatch.setattr( effect_runtime, @@ -419,7 +421,7 @@ def test_deep_doctor_fails_when_present_runtime_cannot_execute_semantics( "status": "probe_failed", "required_for": ["control_plane"], "default_cli_blocking": True, - "minimum_node_version": "22.18.0", + "minimum_node_version": "22.22.3", "detected_node_version": "24.1.0", "semantic_probe": "failed", "recommended_action": "Reinstall LoopX.", diff --git a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts index ab75013e25..a532c63581 100644 --- a/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts +++ b/tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts @@ -7,7 +7,6 @@ import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; -import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; @@ -22,8 +21,6 @@ const GOAL = "synthetic-goal"; const TODO = "todo_task"; const OWNER = "agent-a"; const AGENTS = [OWNER, "agent-b"]; -const sqliteSkip = sqliteRuntimeIdentity().sqlite_authority_qualified - ? false : "requires the qualified SQLite runtime"; async function seeded(provider: "file" | "sqlite", overrides: JsonObject = {}, lease?: JsonObject) { const root = await mkdtemp(join(tmpdir(), `loopx-deferred-${provider}-`)); @@ -76,7 +73,7 @@ function oldLease(status: "active" | "released", expires_at: string): JsonObject for (const provider of ["file", "sqlite"] as const) { test(`${provider}: deferred resume is one CAS transition and never grants execution`, - {skip: provider === "sqlite" && sqliteSkip}, async () => { + async () => { const store = await seeded(provider); const before = await read(store); assert.equal(leaseOwnerRejection({status: "deferred", claimed_by: OWNER, excluded_agents: []}, OWNER, AGENTS), @@ -104,7 +101,7 @@ for (const provider of ["file", "sqlite"] as const) { }); test(`${provider}: expired lease is retired atomically, while live execution and foreign edits fail closed`, - {skip: provider === "sqlite" && sqliteSkip}, async () => { + async () => { const expired = await seeded(provider, {}, oldLease("active", "2026-09-05T22:30:00Z")); const resumed = await executeCoordinationTodoUpdate(expired, resume("retire-expired")); assert.equal(resumed.status, "applied", JSON.stringify(resumed)); @@ -129,7 +126,7 @@ for (const provider of ["file", "sqlite"] as const) { }); test(`${provider}: an unclaimed deferred Todo stays unclaimed; excluded actors cannot reopen it`, - {skip: provider === "sqlite" && sqliteSkip}, async () => { + async () => { const unclaimed = await seeded(provider, {claimed_by: null}); const applied = await executeCoordinationTodoUpdate(unclaimed, resume("unclaimed-resume")); assert.equal(applied.status, "applied", JSON.stringify(applied)); @@ -145,7 +142,7 @@ for (const provider of ["file", "sqlite"] as const) { }); test(`${provider}: deferred supersede closes one Todo and retires expired lease lineage`, - {skip: provider === "sqlite" && sqliteSkip}, async () => { + async () => { const store = await seeded(provider, {}, oldLease("active", "2026-09-05T22:30:00Z")); const input = supersede("supersede-once"); const before = await read(store); diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 8de8321c26..d12240516a 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -112,9 +112,8 @@ async function update(store: AuthorityStore, todoId: string, patch: JsonObject) assert.equal(result.status, "applied"); } const providers = ["file", "sqlite", "postgresql"] as const; -// SQLite authority needs the WAL-reset driver; the public Node minimum ships -// an older one. Skip those rows there rather than fail, and keep the file -// rows running: the qualified runtime job executes every row for real. +// Developer runtimes outside the supported Node range may lack the WAL-reset +// driver. CI asserts SQLite qualification and runs every provider row. const sqliteQualified = sqliteRuntimeIdentity().sqlite_authority_qualified === true; async function fixture(t: TestContext, provider: typeof providers[number]): Promise { if (provider !== "postgresql") { diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index 8a4c05bf69..3cf2fca6fe 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -418,4 +418,4 @@ def test_console_native_scheduler_followup_fails_closed_without_node( ) assert entrypoint.main() == 2 - assert "requires Node.js 22.18.0 or newer" in capsys.readouterr().err + assert "requires Node.js 22.22.3 or newer" in capsys.readouterr().err diff --git a/tests/test_loopx_turn_journal_inspection.py b/tests/test_loopx_turn_journal_inspection.py index 96f7fc0080..a67e4cd123 100644 --- a/tests/test_loopx_turn_journal_inspection.py +++ b/tests/test_loopx_turn_journal_inspection.py @@ -487,7 +487,7 @@ def test_inspection_has_no_python_fallback_when_typescript_runtime_is_missing( _write_journal(tmp_path, _journal()) def missing_node(*_args: object, **_kwargs: object) -> object: - raise RuntimeError("Turn-journal inspection requires Node.js 22.18.0 or newer") + raise RuntimeError("Turn-journal inspection requires Node.js 22.22.3 or newer") monkeypatch.setattr(turn_journal_runtime, "effect_runtime_result", missing_node) @@ -498,7 +498,7 @@ def missing_node(*_args: object, **_kwargs: object) -> object: assert payload == { "ok": False, "schema_version": "loopx_turn_journal_inspection_v1", - "error": "Turn-journal inspection requires Node.js 22.18.0 or newer", + "error": "Turn-journal inspection requires Node.js 22.22.3 or newer", "effects": [], } diff --git a/tests/test_python_ci_workflow.py b/tests/test_python_ci_workflow.py index 9db85cda29..c095772089 100644 --- a/tests/test_python_ci_workflow.py +++ b/tests/test_python_ci_workflow.py @@ -62,14 +62,16 @@ def test_checks_aggregate_requires_both_parallel_lanes( assert (result.returncode == 0) == (kernel == dashboard == "success") -def test_minimum_node_lane_keeps_full_coverage_with_runner_headroom() -> None: +def test_minimum_node_lane_exercises_sqlite_without_a_skip_list() -> None: minimum = WORKFLOW.split(" node-minimum-compatibility:\n", 1)[1].split( " node-forward-compatibility:\n", 1, )[0] - assert "timeout-minutes: 20" in minimum - assert "for test in tests/control_plane_ts/*.test.ts" in minimum - assert 'node --no-warnings --experimental-sqlite --experimental-strip-types --test "${tests[@]}"' in minimum + assert 'node-version: "22.22.3"' in minimum + assert 'LOOPX_TEST_REQUIRE_SQLITE_QUALIFIED: "1"' in minimum + assert "tests/control_plane_ts/sqlite_runtime_admission.test.ts" in minimum + assert "tests/control_plane_ts/deferred_hard_lease_lifecycle.test.ts" in minimum + assert "for test in tests/control_plane_ts/*.test.ts" not in minimum assert "--test-name-pattern" not in minimum assert "shard" not in minimum From 46b66c186e1b4472f8234bb905c606020b23f29b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:09:46 +0800 Subject: [PATCH 04/11] docs: explain the unified Node minimum and upgrade path Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- README.md | 2 +- README.zh-CN.md | 2 +- benchmark/LHTB/README.md | 2 +- benchmark/runtime/RUNTIME.md | 2 +- demo/workspace/README.md | 2 +- .../rfcs/shared-goal-authority-state-provider-v0.md | 7 ++++--- .../shared-goal-authority-state-provider-v0.zh-CN.md | 5 +++-- .../rfcs/typescript-control-plane-migration-v0.md | 2 ++ .../typescript-control-plane-migration-v0.zh-CN.md | 1 + .../typescript-migration-compatibility-notes.md | 2 +- docs/book/chapters/00-reading-guide.md | 3 ++- docs/book/chapters/05-connect-existing-project.md | 2 +- docs/book/en/chapters/00-reading-guide.md | 3 ++- docs/book/en/chapters/05-connect-existing-project.md | 2 +- docs/book/en/index.md | 2 +- docs/book/en/welcome-wagon.md | 2 +- docs/book/index.md | 2 +- docs/book/welcome-wagon.md | 2 +- docs/guides/installing-loopx.md | 10 +++++++--- docs/reference/sqlite-authority-store.md | 10 ++++++---- examples/dev-book-publication-smoke.py | 6 +++--- examples/dev-book-welcome-wagon-smoke.py | 2 +- 22 files changed, 43 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9e875bb560..567858b687 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ More inspectable surfaces: ## Try LoopX -Requirements: Python 3.11+ and Node.js 22.18.0+; Node.js 24 LTS is recommended. +Requirements: Python 3.11+ and Node.js 22.22.3+; Node.js 24 LTS is recommended. Use an active Python environment whose console scripts are on `PATH`; macOS and Linux use a POSIX shell, while native Windows uses PowerShell 7. Node.js runs the managed, idle-exiting TypeScript Effect core; LoopX starts it automatically. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f46c0d153..c17eb10799 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -268,7 +268,7 @@ SWE-Marathon 与 LHTB 每个任务、每种模式仅保留一条有效轨迹;D ## 试用 LoopX -要求:Python 3.11+ 与 Node.js 22.18.0+,推荐使用 Node.js 24 LTS。使用 console +要求:Python 3.11+ 与 Node.js 22.22.3+,推荐使用 Node.js 24 LTS。使用 console scripts 已加入 `PATH` 的 Python 环境;macOS 和 Linux 使用 POSIX shell,原生 Windows 使用 PowerShell 7。Node.js 运行 LoopX 自动启动、空闲退出的 TypeScript Effect core,无需手工维护 daemon。Git 仅用于源码贡献与 clone/canary 工作流。 diff --git a/benchmark/LHTB/README.md b/benchmark/LHTB/README.md index 8d8bb5f582..fe1b81086f 100644 --- a/benchmark/LHTB/README.md +++ b/benchmark/LHTB/README.md @@ -28,7 +28,7 @@ owning a second copy. It does not use the app-server heartbeat agent. ## Run Prerequisites are an LHTB checkout with its Harbor virtual environment, Docker, -the native Codex bundle, a portable Python 3.11+ tree, and Node 22.18.0+. Copy +the native Codex bundle, a portable Python 3.11+ tree, and Node 22.22.3+. Copy `.env.example` to `.env`, set `LHTB_ROOT`, the model gateway, and local runtime paths, then run from this directory. diff --git a/benchmark/runtime/RUNTIME.md b/benchmark/runtime/RUNTIME.md index 682d04942c..50b7935f95 100644 --- a/benchmark/runtime/RUNTIME.md +++ b/benchmark/runtime/RUNTIME.md @@ -111,7 +111,7 @@ export PYTHONPATH="$PWD${PYTHONPATH:+:$PYTHONPATH}" Also set `CODEX_OFFLINE_DIR` (Codex, code-mode sidecar, rg), `LOOPX_PORTABLE_PYTHON` (Python >=3.11 distribution) and `LOOPX_NODE_DIR` -(Node >=22.18.0 distribution). Staging archives the verified commit SHA, never local run +(Node >=22.22.3 distribution). Staging archives the verified commit SHA, never local run artifacts. The host import must come from that checkout, whose tracked files must match HEAD. Commit the candidate before real validation. Baselines stage only the runner/native transport, without installing LoopX skills or initializing diff --git a/demo/workspace/README.md b/demo/workspace/README.md index 189c53e009..e88c328c88 100644 --- a/demo/workspace/README.md +++ b/demo/workspace/README.md @@ -2,7 +2,7 @@ Explore three connected projects in the real LoopX Personal Workspace: a 120-person community event, a home-energy buying guide, and a neighborhood website release. Each has four work roles, 18 delivery tasks across multiple phases, two owner decisions and two scheduled watches. -From a source checkout with Python 3.11+ and Node 22.18.0+: +From a source checkout with Python 3.11+ and Node 22.22.3+: ```bash python -m demo.workspace serve diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index dd1247ca0a..bfce5e4b60 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -1310,9 +1310,10 @@ large-history recovery, fenced backup/restore, supported upgrade/rollback, OS/runtime coverage and the >=10-day elapsed soak; runner completion cannot claim them. See the [SQLite qualification commands](../../reference/sqlite-authority-store.md#reproduce-validation). -The public minimum remains Node 22.18 for File; SQLite additionally requires -synchronous finalization and the WAL-reset fix, with Node 22.22.3/SQLite 3.51.3 -as the reference. Node 24 stays primary and Node 26 stays a non-blocking probe. +The public minimum is now Node 22.22.3 for File and optional SQLite. SQLite +still checks synchronous finalization and the actual WAL-reset-fixed driver; +Node 22.22.3/SQLite 3.51.3 is the reference. Node 24 stays primary and Node 26 +stays a non-blocking probe. **Migration decision points.** Before the first existing-Goal cutover, freeze one exact source lineage/revision under the authority writer fence, import a diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 04b513b131..f50343fdac 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -1018,8 +1018,9 @@ rehearsal 证据(1000 次提交/64 KiB)现为 16 个 checkpoint、63 次 r headroom、完整领域负载、大历史恢复、fenced backup/restore、受支持升级/回滚、 OS/runtime 覆盖和 >=10 天自然时间 soak 仍是 hold,工具跑完不能声称已满足。参见 [SQLite 验证命令](../../reference/sqlite-authority-store.md#reproduce-validation)。 -公开 Node 最低版本 22.18 继续用于 File;SQLite 另需同步 finalization 与 WAL-reset -修复,参考组合为 Node 22.22.3/SQLite 3.51.3。Node 24 主 runtime 与 Node 26 +公开 Node 最低版本已升至 22.22.3,适用于 File 和可选 SQLite;SQLite 仍需检查 +实际内嵌驱动的同步 finalization 与 WAL-reset 修复,参考组合为 Node 22.22.3/ +SQLite 3.51.3。Node 24 主 runtime 与 Node 26 非阻塞 forward probe 保持原合同。 **迁移决策点。** 首次迁移已有 Goal 前,先在 authority writer fence 下冻结精确的源 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 5ec9d57164..8b0d652ffd 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1807,6 +1807,8 @@ cannot be silently relaxed. The migration must not ask users to manage a service. The Python-transition release requires Node.js 22.18.0 or newer, but installer and `loopx doctor` must detect it before normal control-plane work and provide exact remediation. +The current source checkout raises that floor to Node.js 22.22.3 because this +release embeds the WAL-reset-fixed SQLite driver. The wheel and sdist carry the TS source and versioned schemas. The runtime is healthy while idle-exited: `stopped` means the next diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index fa8ef46bd9..7b0a82b076 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -1400,6 +1400,7 @@ transaction 要和 matched durability baseline 比较,而不是套用 2 ms ker 迁移不能要求用户管理服务。Python 过渡版本要求 Node.js 22.18.0 或更新版本, 但 installer 与 `loopx doctor` 必须在正常控制面工作前检测,并给出精确修复方式。 +当前源码版本已将最低要求升至 Node.js 22.22.3,以覆盖内嵌 SQLite 的 WAL-reset 修复。 Wheel 与 sdist 携带 TS source 和版本化 schema。 Runtime 因 idle 退出时仍是健康状态:`stopped` 表示下一次控制面请求会自动拉起, diff --git a/docs/architecture/typescript-migration-compatibility-notes.md b/docs/architecture/typescript-migration-compatibility-notes.md index e1d91f8c07..9d8984e59a 100644 --- a/docs/architecture/typescript-migration-compatibility-notes.md +++ b/docs/architecture/typescript-migration-compatibility-notes.md @@ -120,7 +120,7 @@ full-CLI p95 regression is an owner review gate rather than a silent pass. RFC Section 7 projects only `running`/`stopped`/`unavailable` plus a stable diagnostic code; raw stderr, tokens, local paths, and private runtime metadata -must not be projected. Node.js 22.18.0+ detection with exact remediation is +must not be projected. Node.js 22.22.3+ detection with exact remediation is required before normal control-plane work. Verified status: `loopx check` runs a public-boundary scan over the RFC and diff --git a/docs/book/chapters/00-reading-guide.md b/docs/book/chapters/00-reading-guide.md index aa356c533d..1a7c50a40f 100644 --- a/docs/book/chapters/00-reading-guide.md +++ b/docs/book/chapters/00-reading-guide.md @@ -109,7 +109,8 @@ lifecycle 选择,不是所有贡献的默认终点。 ## 版本基线 当前内容以 LoopX GitHub release `v1.1.0` 为发布锚点;本地命令示例已按 `loopx 1.1.0` 的 -公开 CLI 与协议表面复核。该版本要求 Python 3.11+ 与 Node.js 22.18.0+;后者运行由 LoopX 自动管理、 +公开 CLI 与协议表面复核。该历史版本要求 Python 3.11+ 与 Node.js 22.18.0+;此源码版本要求 +Node.js 22.22.3+。后者运行由 LoopX 自动管理、 空闲后退出的 TypeScript Effect runtime,用户不需要手工维护 daemon。 发布标签、已安装 CLI 与源码 checkout 可能处于不同 revision,因此以下表面尤其需要按实际环境复核: diff --git a/docs/book/chapters/05-connect-existing-project.md b/docs/book/chapters/05-connect-existing-project.md index 5ffe74573d..2146b20cca 100644 --- a/docs/book/chapters/05-connect-existing-project.md +++ b/docs/book/chapters/05-connect-existing-project.md @@ -121,7 +121,7 @@ next_action: 要求: - Python 3.11 或更高版本; -- Node.js 22.18.0 或更高版本,用于 LoopX 自动管理的 TypeScript Effect runtime; +- Node.js 22.22.3 或更高版本,用于 LoopX 自动管理的 TypeScript Effect runtime; - macOS/Linux shell,或 Windows PowerShell 7; - 一个已有 Git 项目。 diff --git a/docs/book/en/chapters/00-reading-guide.md b/docs/book/en/chapters/00-reading-guide.md index 8bff8b6b7c..31d2b68bdc 100644 --- a/docs/book/en/chapters/00-reading-guide.md +++ b/docs/book/en/chapters/00-reading-guide.md @@ -121,7 +121,8 @@ Do not bypass a newer permission or lifecycle check just to make an older exampl ## Version baseline The current release anchor is LoopX GitHub release `v1.1.0`. Local command examples were checked against -the public `loopx 1.1.0` CLI and protocol surface. This release requires Python 3.11+ and Node.js 22.18.0+. +the public `loopx 1.1.0` CLI and protocol surface. That release required Python 3.11+ and Node.js 22.18.0+; +this checkout requires Node.js 22.22.3+. LoopX starts and reuses its managed, idle-exiting TypeScript Effect runtime automatically; users do not operate that runtime as a manual daemon. diff --git a/docs/book/en/chapters/05-connect-existing-project.md b/docs/book/en/chapters/05-connect-existing-project.md index 30ef8b91c2..f11a735c8c 100644 --- a/docs/book/en/chapters/05-connect-existing-project.md +++ b/docs/book/en/chapters/05-connect-existing-project.md @@ -130,7 +130,7 @@ diagnosis and choices only. Do not write state, commit, or push. Prerequisites: - Python 3.11 or later; -- Node.js 22.18.0 or later for the LoopX-managed TypeScript Effect runtime; +- Node.js 22.22.3 or later for the LoopX-managed TypeScript Effect runtime; - a macOS or Linux shell, or Windows PowerShell 7; - an existing Git project. diff --git a/docs/book/en/index.md b/docs/book/en/index.md index c6476cfdd9..dc08501566 100644 --- a/docs/book/en/index.md +++ b/docs/book/en/index.md @@ -80,7 +80,7 @@ versioned or optional functionality, not the default shape of every contribution - Site generator: MkDocs Material - Hosting: GitHub Pages - LoopX release anchor: `v1.1.0` -- Runtime prerequisites: Python 3.11+ and Node.js 22.18.0+ +- Runtime prerequisites: Python 3.11+ and Node.js 22.22.3+ The official public protocols remain authoritative for protocol facts. Commands that change across releases remain authoritative in the release you use, its current `--help`, and official documentation. diff --git a/docs/book/en/welcome-wagon.md b/docs/book/en/welcome-wagon.md index d19fce6840..02d3bfb895 100644 --- a/docs/book/en/welcome-wagon.md +++ b/docs/book/en/welcome-wagon.md @@ -65,7 +65,7 @@ loopx doctor git status --short --branch ``` -Current LoopX releases require Python 3.11+ and Node.js 22.18.0+. If `doctor` fails, repair the installation +This checkout requires Python 3.11+ and Node.js 22.22.3+. If `doctor` fails, repair the installation through [Installing LoopX](/loopx/docs/guides/installing-loopx/) before writing project state in a broken environment. diff --git a/docs/book/index.md b/docs/book/index.md index 11916331a7..46df27696c 100644 --- a/docs/book/index.md +++ b/docs/book/index.md @@ -71,7 +71,7 @@ Extension 只是可独立版本化和交付的一种路径。 - 站点生成器:MkDocs Material; - 在线发布:GitHub Pages; - LoopX 发布锚点:`v1.1.0`; -- 运行时前提:Python 3.11+ 与 Node.js 22.18.0+。 +- 运行时前提:Python 3.11+ 与 Node.js 22.22.3+。 协议解释以 LoopX 官方公开合同为事实源。易变化的命令仍以对应发布物、官方文档和当前 `--help` 为准;本书负责教学顺序与心智模型,不成为另一份完整命令参考。 diff --git a/docs/book/welcome-wagon.md b/docs/book/welcome-wagon.md index 2bb065ad8a..5e6dcfb049 100644 --- a/docs/book/welcome-wagon.md +++ b/docs/book/welcome-wagon.md @@ -62,7 +62,7 @@ loopx doctor git status --short --branch ``` -LoopX 当前要求 Python 3.11+ 与 Node.js 22.18.0+。如果 `doctor` 失败,先按 +此源码版本要求 Python 3.11+ 与 Node.js 22.22.3+。如果 `doctor` 失败,先按 [安装指南](/loopx/docs/guides/installing-loopx/)修复安装,不要在错误环境里继续写项目状态。 diff --git a/docs/guides/installing-loopx.md b/docs/guides/installing-loopx.md index fd8d498a04..a1f6a920d0 100644 --- a/docs/guides/installing-loopx.md +++ b/docs/guides/installing-loopx.md @@ -24,7 +24,7 @@ development and qualification surface, not a second implicit package channel. The archive snapshot remains a recovery path rather than a competing default. LoopX's Effect Program core runs in a managed, idle-exiting TypeScript runtime -and requires Node.js 22.18.0 or later; Node.js 24 LTS is the recommended primary +and requires Node.js 22.22.3 or later; Node.js 24 LTS is the recommended primary runtime. LoopX starts and reuses that local runtime automatically; users do not run a daemon manually. The runtime binds only to loopback, authenticates requests with a user-private token, rotates when the packaged Effect core @@ -35,7 +35,11 @@ engine. The same doctor projection exposes `runtime_lifecycle.state` as `running`, `stopped`, or `unavailable`, plus a public-safe `diagnostic_code`; the App can render this projection without inventing a second health model. `stopped` is healthy and means the idle-exited runtime will restart on the next -control-plane request. Validate Node before installing or upgrading LoopX: +control-plane request. Validate Node before installing or upgrading LoopX. +Upgrading from an older Node 22 installation requires restarting the managed +runtime with `loopx doctor --restart-runtime` after the new Node is on `PATH`. +SQLite remains opt-in and checks the actual embedded SQLite version before +opening authority state. The CI and release lanes use Node.js 24 LTS. Node.js 26 remains a non-blocking forward-compatibility probe and is not a supported-version promise. After the @@ -45,7 +49,7 @@ flags. ```bash node --version -# v22.18.0 or newer +# v22.22.3 or newer ``` Use `loopx doctor --deep` after installation to start the managed runtime and diff --git a/docs/reference/sqlite-authority-store.md b/docs/reference/sqlite-authority-store.md index ac485710fd..1452f72795 100644 --- a/docs/reference/sqlite-authority-store.md +++ b/docs/reference/sqlite-authority-store.md @@ -112,9 +112,10 @@ qualify the complete D2 profile. This intentionally rejects SQLite runtimes previously accepted by the statement-only probe, including vulnerable drivers shipped with older Node 22 -releases. The public Node minimum remains 22.18 for the default File path; -SQLite requires the additional fix. No provider selection changes and no -fallback to File occur when an explicitly selected SQLite runtime is rejected. +releases. The public Node minimum is now 22.22.3 for both File and optional +SQLite use; SQLite also checks the actual embedded driver. Provider selection +does not change, and an explicitly selected SQLite runtime never falls back to +File when rejected. The optional driver is still opened only after opt-in. The serving runtime loads it in-process once at startup for the read-only identity probe described below; that probe opens no database file and creates no authority state, and @@ -400,7 +401,8 @@ retained commit. That row stays `missing` for a rehearsal, exactly like every other formal budget. SQLite 资格参考使用 Node 22.22.3/SQLite 3.51.3;打开前同时检查实际 WAL 修复版本 -和 statement 关闭行为。公开 Node 最低版本 22.18 继续用于默认 File 路径。显式 +和 statement 关闭行为。公开 Node 最低版本已升至 22.22.3,同时适用于默认 File +和可选 SQLite;SQLite 仍检查实际内嵌驱动。显式 SQLite 选择遇到不合格 runtime 会拒绝,不会改默认 provider 或静默回退。 托管 Effect runtime 按用户与源码修订复用,因此真正服务某个 Goal 的 Node/SQLite diff --git a/examples/dev-book-publication-smoke.py b/examples/dev-book-publication-smoke.py index 6d49909d0c..639bb95593 100644 --- a/examples/dev-book-publication-smoke.py +++ b/examples/dev-book-publication-smoke.py @@ -465,7 +465,7 @@ def main() -> int: "en/index.md", ( (f"LoopX 发布锚点:`{release_tag}`", f"LoopX release anchor: `{release_tag}`"), - ("运行时前提:Python 3.11+ 与 Node.js 22.18.0+", "Runtime prerequisites: Python 3.11+ and Node.js 22.18.0+"), + ("运行时前提:Python 3.11+ 与 Node.js 22.22.3+", "Runtime prerequisites: Python 3.11+ and Node.js 22.22.3+"), ("TypeScript owner", "TypeScript owners"), ("这不是两套可独立演进的 控制面", "These are not two independently evolving control planes"), ), @@ -476,7 +476,7 @@ def main() -> int: ( ("语义镜像", "semantic mirrors"), ("Python 3.11+", "Python 3.11+"), - ("Node.js 22.18.0+", "Node.js 22.18.0+"), + ("Node.js 22.22.3+", "Node.js 22.22.3+"), ("用户不需要手工维护 daemon", "users do not operate that runtime as a manual daemon"), ("TypeScript 已拥有", "TypeScript owns"), ("Python CLI 仍负责", "Python CLI still owns"), @@ -545,7 +545,7 @@ def main() -> int: "chapters/05-connect-existing-project.md", "en/chapters/05-connect-existing-project.md", ( - ("Node.js 22.18.0", "Node.js 22.18.0"), + ("Node.js 22.22.3", "Node.js 22.22.3"), ("Windows PowerShell 7", "Windows PowerShell 7"), ("loopx doctor --deep", "loopx doctor --deep"), ("用户不需要手工维护 daemon", "users do not supervise a daemon manually"), diff --git a/examples/dev-book-welcome-wagon-smoke.py b/examples/dev-book-welcome-wagon-smoke.py index 940727ccf9..eee06d64a6 100644 --- a/examples/dev-book-welcome-wagon-smoke.py +++ b/examples/dev-book-welcome-wagon-smoke.py @@ -61,7 +61,7 @@ SHARED_BOUNDARY_MARKERS = ( "Python 3.11+", - "Node.js 22.18.0+", + "Node.js 22.22.3+", ".loopx/", ".codex/goals/", ".local/", From 294e67397981b219299904195db098a27ca344fb Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:30:46 +0800 Subject: [PATCH 05/11] test: stop pinning retired README copy in showcase smoke Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- examples/showcase-catalog-smoke.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/examples/showcase-catalog-smoke.py b/examples/showcase-catalog-smoke.py index 49552b712f..a147dd31a0 100644 --- a/examples/showcase-catalog-smoke.py +++ b/examples/showcase-catalog-smoke.py @@ -275,14 +275,9 @@ def main() -> int: ): assert phrase in feedback_loop, phrase for phrase in ( - "Loop engineering for long-horizon AI agents and peer agent teams.", - "The open, provider-neutral, stateful control plane for long-horizon agents.", "https://loopx-project.github.io/loopx/", - "## Advanced Paths", "docs/assets/long-running-loop-openviking-trajectory.png", "docs/assets/long-running-loop-ml-experiment-trajectory.png", - "### Presets and Auto Research", - "### Review Agent Work", "## Evidence", "### Used In Real Projects", "docs/showcases/cases/independent-cpp-accuracy-long-run.md", From 997225df38a3f4a94bf1452da8aabd9331839694 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:45:31 +0800 Subject: [PATCH 06/11] fix(workspace): find accepted team reports beyond the first page Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../personal-workspace/goal-team-results.tsx | 52 +++++++++++++------ .../rfcs/live-team-workspace-v0.md | 4 ++ .../rfcs/live-team-workspace-v0.zh-CN.md | 4 +- .../team-evidence.mjs | 32 +++++++++++- loopx/web/chat/asset-retention.json | 4 +- .../{index-CFEaAATG.js => index-BOit3ggY.js} | 20 +++---- loopx/web/chat/index.html | 2 +- 7 files changed, 86 insertions(+), 32 deletions(-) rename loopx/web/chat/assets/{index-CFEaAATG.js => index-BOit3ggY.js} (81%) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx index f5b960c52f..7324683ca0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-results.tsx @@ -7,20 +7,22 @@ import {GoalTeamLineage} from "./goal-team-lineage"; type Selection = {operationId: string; ref?: string; sha256?: string}; const readableRows = (page: DelegationInventory) => page.items.filter(row => row.operation_id && row.status === "accepted" && !row.recovery_required && row.artifacts?.length); +const AUTO_DISCOVERY_PAGES = 3; +const MAX_INSPECTED_PAGES = 10; /** Read-only entry in the original conversation, using the same scoped delegation API. */ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string; zh: boolean; refreshKey: string}) { - const [page, setPage] = useState(null); + const [pages, setPages] = useState([]); const [selection, setSelection] = useState<{result: DelegationReadback; artifact: TeamArtifact} | null>(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const generation = useRef(0); const chosen = useRef(null); - const cursor = useRef(undefined); + const inspectedPages = useRef(1); const focusReport = useRef(false); const report = useRef(null); - useEffect(() => {chosen.current = null; cursor.current = undefined;}, [sessionId]); - useEffect(() => {void list(cursor.current); return () => {generation.current++;};}, [sessionId, refreshKey]); + useEffect(() => {chosen.current = null; inspectedPages.current = 1;}, [sessionId]); + useEffect(() => {void list(); return () => {generation.current++;};}, [sessionId, refreshKey]); useEffect(() => { if (selection && focusReport.current) {report.current?.focus(); focusReport.current = false;} }, [selection]); @@ -39,21 +41,33 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string; setSelection({result: value, artifact}); } } - async function list(nextCursor?: string, nextPage = false) { + async function list(nextPage = false) { const current = ++generation.current; if (nextPage) chosen.current = null; - cursor.current = nextCursor; + const minimumPages = Math.min(MAX_INSPECTED_PAGES, inspectedPages.current + (nextPage ? 1 : 0)); focusReport.current = false; - setBusy(true); setError(""); setPage(null); setSelection(null); + setBusy(true); setError(""); setPages([]); setSelection(null); try { - const value = await fetchLoopXTeamWork(sessionId, nextCursor); - if (current !== generation.current) return; - setPage(value); + const observed: DelegationInventory[] = []; + let nextCursor: string | undefined; + while (observed.length < minimumPages || + (!chosen.current && !observed.some(page => readableRows(page).length) && observed.length < AUTO_DISCOVERY_PAGES)) { + const value = await fetchLoopXTeamWork(sessionId, nextCursor); + if (current !== generation.current) return; + observed.push(value); + if (!value.has_more) break; + if (!value.next_cursor || (nextCursor && value.next_cursor <= nextCursor)) { + throw new Error(zh ? "执行分页无法继续核验;请重新读取。" : "Execution paging cannot be verified; refresh the list."); + } + nextCursor = value.next_cursor; + } + inspectedPages.current = observed.length; + setPages(observed); // Keep the reader's exact version. A refresh must never silently replace // it with a newer artifact or move to a different member's result. let target = chosen.current; if (!target) { - const first = readableRows(value)[0]; + const first = observed.flatMap(readableRows)[0]; const artifact = first?.artifacts?.find(row => isMarkdownArtifact(row.ref)) ?? first?.artifacts?.[0]; if (first?.operation_id && artifact) target = {operationId: first.operation_id, ...artifact}; } @@ -74,21 +88,25 @@ export function GoalTeamResults({sessionId, zh, refreshKey}: {sessionId: string; catch (failure) {if (current === generation.current) setError(`${zh ? "无法核验,已清除上次报告。" : "Cannot verify; previous report cleared."} ${String(failure)}`);} finally {if (current === generation.current) setBusy(false);} } - const rows = page ? readableRows(page) : []; + const rows = pages.flatMap(readableRows); + const lastPage = pages.at(-1); return

{zh ? "团队成果" : "Team results"}

-
+ {busy ?

{zh ? "正在核验产物…" : "Verifying artifacts…"}

: null} {error ?

{error}

: null} - {page && !page.page_readback_complete ?

{zh ? "部分工作无法核验,请在团队执行中检查。" : "Some work cannot be verified; inspect Team execution."}

: null} + {pages.some(page => !page.page_readback_complete) ?

{zh ? "已检查的工作中有无法核验的记录;当前报告只代表它自己的验收,不代表整个团队。" : "Some inspected work cannot be verified. This report reflects only its own acceptance, not the whole team."}

: null}
- {page ?