diff --git a/.github/product-version-operations/forge-r58-projectless-idle-20261017.json b/.github/product-version-operations/forge-r58-projectless-idle-20261017.json new file mode 100644 index 0000000..47de2c5 --- /dev/null +++ b/.github/product-version-operations/forge-r58-projectless-idle-20261017.json @@ -0,0 +1,16 @@ +{ + "baseline_version": "2.22.0", + "classification_rationale": "L3-PROJECTLESS-INSTALLATION-IDLE-V1-20261017", + "component": "product", + "event_lineage": "L3-PROJECTLESS-INSTALLATION-IDLE-V1-20261017", + "expected_head": "523a13f3196c096da217421e74130df474fadcc6", + "operation_id": "forge-r58-projectless-idle-20261017", + "policy_revision": "forge-bootstrap-release-cadence-v2", + "product": "forge", + "projection_paths": "product-version.json", + "release_class": "PATCH", + "requested_bump": "patch", + "requested_version": null, + "schema_version": "1", + "target_version": "2.22.1" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89aa17d..652b81f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,23 @@ jobs: name: forge-genesis-bootstrap-${{ github.sha }} path: ${{ runner.temp }}/forge-genesis-proof/*.public.json if-no-files-found: error + - name: Projectless installed scheduler HTTP and restart + timeout-minutes: 3 + run: | + set -euo pipefail + wheel=("$RUNNER_TEMP"/forge-genesis-wheel/forge_autonomy-*.whl) + test "${#wheel[@]}" -eq 1 + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/forge-genesis-installed/bin/python" -I \ + "$GITHUB_WORKSPACE/scripts/qualification/qualify_installed_projectless_idle.py" \ + --wheel "${wheel[0]}" --source-revision "$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" \ + --output-dir "$RUNNER_TEMP/forge-projectless-idle" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: forge-projectless-idle-${{ github.sha }} + path: ${{ runner.temp }}/forge-projectless-idle/installed-projectless-idle.public.json + if-no-files-found: error - run: bash scripts/validate.sh env: FORGE_WITH_COVERAGE: "1" diff --git a/.github/workflows/forge-production-release.yml b/.github/workflows/forge-production-release.yml index 1811d0c..6677fc1 100644 --- a/.github/workflows/forge-production-release.yml +++ b/.github/workflows/forge-production-release.yml @@ -93,6 +93,12 @@ jobs: PYTHONNOUSERSITE=1 PYTHONSAFEPATH=1 "$RUNNER_TEMP/forge-wheel/bin/python" -I -c 'import forge, pathlib, tempfile; from importlib.metadata import version; from forge._version import canonical_version; from forge.qualification.installed_smoke import run, assert_runtime_persistence; assert version("forge-autonomy") == canonical_version() == __import__("os").environ["VERSION"]; assert "/site-packages/forge/" in str(pathlib.Path(forge.__file__).resolve()); run(); root=tempfile.TemporaryDirectory(); workspace=pathlib.Path(root.name)/"workspace"; workspace.mkdir(); assert_runtime_persistence(str(workspace), str(pathlib.Path(root.name)/"runtime"), canonical_version()); root.cleanup()' "$RUNNER_TEMP/forge-wheel/bin/python" -I "$GITHUB_WORKSPACE/scripts/qualification/qualify_installed_installation_detach.py" --wheel "$GITHUB_WORKSPACE/dist/forge_autonomy-$VERSION-py3-none-any.whl" --source-revision "$SOURCE_SHA" --output-dir "$RUNNER_TEMP/forge-installation-detach-wheel" cp "$RUNNER_TEMP/forge-installation-detach-wheel/installed-installation-detach.public.json" "$GITHUB_WORKSPACE/dist/installed-installation-detach-qualified.json" + "$RUNNER_TEMP/forge-wheel/bin/python" -I \ + "$GITHUB_WORKSPACE/scripts/qualification/qualify_installed_projectless_idle.py" \ + --wheel "$GITHUB_WORKSPACE/dist/forge_autonomy-$VERSION-py3-none-any.whl" \ + --source-revision "$SOURCE_SHA" --output-dir "$RUNNER_TEMP/forge-projectless-idle-wheel" + cp "$RUNNER_TEMP/forge-projectless-idle-wheel/installed-projectless-idle.public.json" \ + "$GITHUB_WORKSPACE/dist/installed-projectless-idle-qualified.json" "$RUNNER_TEMP/forge-wheel/bin/forge" --help >/dev/null test "$("$RUNNER_TEMP/forge-wheel/bin/forge" --version)" = "$VERSION" "$RUNNER_TEMP/forge-wheel/bin/python" -I -m forge.qualification.criterion_completion --output-dir "$RUNNER_TEMP/forge-criterion-wheel" diff --git a/docs/architecture/FORGE_SERVER_RUNTIME_LIFECYCLE_V1.md b/docs/architecture/FORGE_SERVER_RUNTIME_LIFECYCLE_V1.md index b55b8cd..1cc6854 100644 --- a/docs/architecture/FORGE_SERVER_RUNTIME_LIFECYCLE_V1.md +++ b/docs/architecture/FORGE_SERVER_RUNTIME_LIFECYCLE_V1.md @@ -306,3 +306,22 @@ normal recovery. Its controller correction is a new exact source binding; the original 2.7.38 wheel and historical controlled-interruption evidence retain their identities. This matrix makes no host reboot, power-loss or installer acceptance claim. + +## Projectless installation idle (R58) + +A new installed instance can be scheduler `IDLE` before a project is selected. +The Server still requires its instance-owned provider readiness. Under the +canonical runtime mutation lease it opens a validated read-only storage snapshot +and proves absence of Mission state/allocation/intake/projection, submissions, +approved worksets, dispatcher/planning/bootstrap portfolio state, execution-host +bindings and immutable exchange audit. Only this narrow empty-work proof avoids +composing the project-bound execution runtime. Missing, corrupt, fenced or +unknown storage fails closed. Any retained work/history takes the existing +strict runtime path with all provider, project, EP-host and authority checks; +this change does not classify historical terminal records as an empty instance. + +Installation connectivity is still independently authenticated. Installation +service readiness is not project authorization or execution readiness. No +project, Mission, grant or repository is created by the empty probe. Each tick +rechecks storage under the shared lease; no cached empty classification survives +new work or restart. Runtime storage schema remains46. diff --git a/forge/server_runtime.py b/forge/server_runtime.py index c446268..34ed00d 100644 --- a/forge/server_runtime.py +++ b/forge/server_runtime.py @@ -1429,6 +1429,28 @@ def _tick(self) -> None: provider = self.services.provider_readiness() if not provider.get("ready"): raise ForgeServerRuntimeError("instance-owned planning provider is not ready") + # A fresh installation has no project execution context yet. Prove the + # narrow empty-work condition from one validated read-only snapshot, + # under the same lease used by Mission intake and scheduler mutation. + # Any work/history, unreadable store or uncertainty retains the normal + # strict composition below; no peer exception is treated as IDLE. + from .runtime.bootstrap import RuntimeBootstrap + with RuntimeServiceLock(self.root / "forge.db").acquire(): + database = RuntimeBootstrap(data_root=self.root, forge_version=canonical_version()).open_read_snapshot() + try: + work_tables = ("mission_state", "mission_id_allocations", "mission_intake_evidence", + "mission_runtime_projections", "scheduler_submissions", "approved_worksets", + "dispatcher_state", "planning_state", "bootstrap_portfolio_state", + "execution_host_bindings", "execution_host_exchange_audit") + empty = all(database._connection.execute(f"SELECT 1 FROM {table} LIMIT 1").fetchone() is None + for table in work_tables) + if empty: + if self.root_identity.drifted(): + raise ForgeServerRuntimeError("Forge Server selected instance root changed") + self.state.scheduler("IDLE") + return + finally: + database.close() with InstalledDynamicMissionRuntime.open(str(self.root), provider_id=self.provider_id) as runtime: service = ForgeRuntimeService(_ResumeOnlyLoop(runtime), runtime.states, runtime_database=runtime.database) tick = service.tick() diff --git a/product-version.json b/product-version.json index a405f93..ef004d8 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge", "schema_version": 1, - "version": "2.22.0" + "version": "2.22.1" } diff --git a/scripts/qualification/qualify_installed_candidate_decisions.py b/scripts/qualification/qualify_installed_candidate_decisions.py index f5f34fd..12a0c14 100644 --- a/scripts/qualification/qualify_installed_candidate_decisions.py +++ b/scripts/qualification/qualify_installed_candidate_decisions.py @@ -23,6 +23,16 @@ def boundary_counts(root): decisions=db.execute('SELECT count(*) FROM governance_decisions').fetchone()[0] return intents,decisions,receipts +def _publish_boundary_pid(root): + # Publish only complete PID bytes; existence is the parent's crash signal. + pending = root / 'decision-boundary.pending.private' + pending.touch(mode=0o600, exist_ok=False) + try: + pending.write_text(str(os.getpid())) + os.replace(pending, root / 'decision-boundary.ready.private') + finally: + pending.unlink(missing_ok=True) + def phase(root,stage): cases=test_driver();q=cases.qual;original=q.utils.fixture.IDENTITY payload=json.loads((root/'decision-request.private.json').read_text());baseline=0 if payload['kind']=='BUSINESS' else 1 @@ -31,7 +41,7 @@ def identity(): intents,decisions,receipts=boundary_counts(root) wanted={'crash-before-effect':(baseline,baseline),'crash-between-stores':(baseline+1,baseline),'crash-after-effect':(baseline+1,baseline+1)}[stage] if intents==baseline+1 and (decisions,receipts)==wanted: - (root/'decision-boundary.ready.private').write_text(str(os.getpid()));time.sleep(20) + _publish_boundary_pid(root);time.sleep(20) return original with patch.object(q.utils.composition.MacOSGeneratedUIDIdentityAdapter,'resolve',side_effect=identity),ExitStack() as stack: server=q.ForgeServerRuntime(data_root=root/'runtime',credential_file=root/'admin.private',host='127.0.0.1',port=0,provider_id=q.utils.fixture.PROVIDER) diff --git a/scripts/qualification/qualify_installed_http_successor.py b/scripts/qualification/qualify_installed_http_successor.py index dbdaa82..b76e534 100644 --- a/scripts/qualification/qualify_installed_http_successor.py +++ b/scripts/qualification/qualify_installed_http_successor.py @@ -1283,6 +1283,7 @@ def _phase(root: Path, scenario: str, phase: str, endpoint: str) -> None: if time.monotonic() >= deadline: raise RuntimeError("concurrent start barrier timed out") time.sleep(0.01) + _install_late_preflight(runtime, root, phase) try: runtime.start(mission_id, _initial_truth()) except Exception as error: @@ -1291,6 +1292,8 @@ def _phase(root: Path, scenario: str, phase: str, endpoint: str) -> None: }) else: fixture._write(root / f"{phase}.race.private.json", {"result": "STARTED"}) + finally: + _mark_start_finished(root, phase) elif phase == "after-a-cut": def before_successor() -> bool: observed = runtime.states.get(mission_id) @@ -2512,9 +2515,77 @@ def _scenario(root: Path, scenario: str, wheel: Path, *, observer=None) -> dict: **({"recovery": recovery_summary} if recovery_summary is not None else {})} -def _concurrent_start_case(root: Path, wheel: Path) -> dict: +def _install_late_preflight(runtime, root, phase): + if not (root / "controlled-late-start.private").exists(): + return + original_preflight = runtime.preflight + def controlled_preflight(*args, **kwargs): + result = original_preflight(*args, **kwargs) + marker = root / ("race-start-b.preflight.private" if phase == "race-start-a" else "race-start-a.finished.private") + if phase == "race-start-b": + (root / "race-start-b.preflight.private").write_text("observed") + deadline = time.monotonic() + 20 + while not marker.exists(): + if time.monotonic() >= deadline: + raise RuntimeError("controlled late-start barrier timed out") + time.sleep(0.01) + return result + runtime.preflight = controlled_preflight + + +def _mark_start_finished(root, phase): + if phase == "race-start-a" and (root / "controlled-late-start.private").exists(): + (root / "race-start-a.finished.private").write_text("finished") + + +def _validate_late_control(args, parser): + if args.late_start_control and (args.scenario != "concurrent-start" or args.phase is not None): + parser.error("late-start control requires only the concurrent-start parent case") + + +def _attach_single_flight(report, error, scenario): + if scenario == "concurrent-start" and hasattr(error, "single_flight_observation"): + report["failure"]["single_flight"] = error.single_flight_observation + + +def _concurrent_accepted(outcomes, submissions, invocations): + """One actual winner, one exact canonical refusal, and one original effect.""" + expected = { + "RuntimeServiceBusy": ("canonical runtime is busy",), + "MissionStateStoreError": ( + "mission state transition CREATED -> CREATED is not permitted", + "mission state transition WAITING_FOR_EXECUTION -> CREATED is not permitted", + ), + "InstalledDynamicMissionError": ("public start requires a canonically admitted zero-Action Mission",), + } + rejection = next((item for item in outcomes if item.get("result") == "REJECTED"), {}) + return (sorted(item.get("result", "UNKNOWN") for item in outcomes) == ["REJECTED", "STARTED"] + and rejection.get("reason") in expected.get(rejection.get("type"), ()) + and submissions == 1 and invocations == 1) + + +def _concurrent_observation(outcomes, submissions, invocations): + """Closed public facts, excluding arbitrary exception messages and paths.""" + from forge.state import MissionExecutionStatus + known_types = {"RuntimeServiceBusy", "MissionStateStoreError", "InstalledDynamicMissionError"} + known_reasons = { + "canonical runtime is busy": "RUNTIME_BUSY", + "public start requires a canonically admitted zero-Action Mission": "NOT_ZERO_ACTION", + **{f"mission state transition {state.value} -> CREATED is not permitted": + "TRANSITION_" + state.value + "_TO_CREATED_DENIED" for state in MissionExecutionStatus}, + } + return {"outcomes": [{"result": item.get("result") if item.get("result") in {"STARTED", "REJECTED"} else "UNKNOWN", + "type": item.get("type") if item.get("type") in known_types else "OTHER", + "reason_code": known_reasons.get(item.get("reason"), "UNCLASSIFIED")} + for item in outcomes], + "submission_count": submissions, "planner_invocation_count": invocations} + + +def _concurrent_start_case(root: Path, wheel: Path, late_start_control: bool = False) -> dict: """Race two installed Forge processes at the public zero-Action start gate.""" root.mkdir() + if late_start_control: + (root / "controlled-late-start.private").write_text("fixture-only") fixture._write(root / "artifact-a.json", { "report": {"fields": ["report_data"]}, "policy": {"authorization_required": True}, @@ -2568,17 +2639,12 @@ def _concurrent_start_case(root: Path, wheel: Path) -> dict: handle.close() outcomes = [fixture._read(root / f"{phase}.race.private.json") for phase in ("race-start-a", "race-start-b")] - rejection = next((item for item in outcomes if item["result"] == "REJECTED"), {}) - expected_rejections = { - RuntimeServiceBusy.__name__: "canonical runtime is busy", - MissionStateStoreError.__name__: "mission state transition CREATED -> CREATED is not permitted", - InstalledDynamicMissionError.__name__: "public start requires a canonically admitted zero-Action Mission", - } - if (sorted(item["result"] for item in outcomes) != ["REJECTED", "STARTED"] - or rejection.get("reason") != expected_rejections.get(rejection.get("type")) - or len(simulator.submission_ids()) != 1 - or len(fixture._read(root / "provider-inputs.private.json")) != 1): - raise RuntimeError("concurrent starters crossed the single-flight Action boundary") + if not _concurrent_accepted(outcomes, len(simulator.submission_ids()), + len(fixture._read(root / "provider-inputs.private.json"))): + error = RuntimeError("concurrent starters crossed the single-flight Action boundary") + error.single_flight_observation = _concurrent_observation( + outcomes, len(simulator.submission_ids()), len(fixture._read(root / "provider-inputs.private.json"))) + raise error submission_id = simulator.submission_ids()[0] payload = simulator.submitted_payload(submission_id) pending = validate_fixture( @@ -2743,6 +2809,7 @@ def main() -> int: parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--source-revision") parser.add_argument("--scenario", choices=SCENARIOS) + parser.add_argument("--late-start-control", action="store_true") parser.add_argument("--governance-case", choices=GOVERNANCE_CASES) parser.add_argument("--preflight-case", choices=tuple(PREFLIGHT_CASES)) parser.add_argument("--provider-case", choices=PROVIDER_CASES) @@ -2759,6 +2826,7 @@ def main() -> int: "preflight-stage", "preflight-first", "preflight-repeat")) parser.add_argument("--endpoint") args = parser.parse_args() + _validate_late_control(args, parser) if args.source_revision is not None and ( len(args.source_revision) != 40 or any(character not in "0123456789abcdef" for character in args.source_revision) ): @@ -2943,7 +3011,7 @@ def main() -> int: try: if scenario == "concurrent-start": summaries.append(_with_case_cleanup( - root / scenario, _concurrent_start_case, args.wheel.resolve(), + root / scenario, _concurrent_start_case, args.wheel.resolve(), args.late_start_control, )) else: summaries.append(_with_case_cleanup( @@ -2957,6 +3025,7 @@ def main() -> int: for frame in traceback.extract_tb(error.__traceback__)], "stage_trace": fixture._read( root / scenario / "stage-trace.public.json", [])}) + _attach_single_flight(report, error, scenario) fixture._write(root / "installed-http-successor.public.json", report) print(json.dumps(report, sort_keys=True)) return 1 diff --git a/scripts/qualification/qualify_installed_projectless_idle.py b/scripts/qualification/qualify_installed_projectless_idle.py new file mode 100644 index 0000000..3a6a013 --- /dev/null +++ b/scripts/qualification/qualify_installed_projectless_idle.py @@ -0,0 +1,172 @@ +"""Installed HTTP/restart proof; only external OS/keychain/Codex fixtures substituted.""" +import argparse +from hashlib import sha256 +from importlib.util import spec_from_file_location, module_from_spec +import json +import os +from pathlib import Path +import socket +import sqlite3 +import subprocess +import sys +from tempfile import TemporaryDirectory +import time +from unittest.mock import patch +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +SOURCE=Path(__file__).resolve().parents[2] +TOKEN='synthetic-installation-only-token' +ADMIN='synthetic-forge-server-token' + + +def child(root,port): + from forge.secure_store import SecretState + from forge.operator_identity import NamedOperatorIdentity + from forge.server_runtime import ForgeServerRuntime + # External credential fixture, no actual Keychain read or write. + with patch('forge.installation_pairing.MacOSKeychainSecureStoreAdapter.resolve',return_value=(SecretState.RESOLVABLE,TOKEN)), patch('forge.operator_identity.MacOSGeneratedUIDIdentityAdapter.resolve',return_value=NamedOperatorIdentity('synthetic-projectless-operator',os.getuid())): + app=ForgeServerRuntime(data_root=root,credential_file=root/'server-credential',host='127.0.0.1',port=port,tick_interval=.05) + app.serve_forever() + + +def _wait_readiness(process, port, wanted): + deadline=time.monotonic()+15;body=None + while time.monotonic() CREATED is not permitted'},{'result':'STARTED'}],1,1) + self.assertEqual(value['outcomes'][0]['reason_code'],'TRANSITION_WAITING_FOR_EXECUTION_TO_CREATED_DENIED') + self.assertEqual((value['submission_count'],value['planner_invocation_count']),(1,1)) + + def test_unknown_exception_text_and_identifiers_are_never_published(self): + value=module._concurrent_observation([{'result':'private-token','type':'/private/secret-path','reason':'Bearer private-token','actor':'private-actor'}],2,3) + raw=json.dumps(value) + for secret in ('private-token','secret-path','Bearer','private-actor'):self.assertNotIn(secret,raw) + self.assertEqual(value['outcomes'][0],{'result':'UNKNOWN','type':'OTHER','reason_code':'UNCLASSIFIED'}) + + def test_busy_and_duplicate_effect_counts_remain_exact_observations(self): + value=module._concurrent_observation([{'result':'REJECTED','type':'RuntimeServiceBusy','reason':'canonical runtime is busy'}],2,2) + self.assertEqual(value['outcomes'][0]['reason_code'],'RUNTIME_BUSY') + self.assertEqual((value['submission_count'],value['planner_invocation_count']),(2,2)) + + def test_exact_observed_late_canonical_refusal_is_still_one_winner(self): + outcomes=[{'result':'STARTED'},{'result':'REJECTED','type':'MissionStateStoreError','reason':'mission state transition WAITING_FOR_EXECUTION -> CREATED is not permitted'}] + self.assertTrue(module._concurrent_accepted(outcomes,1,1)) + for counts in ((2,1),(1,2),(0,1),(1,0)): + self.assertFalse(module._concurrent_accepted(outcomes,*counts)) + + def test_other_transitions_untyped_errors_and_two_winners_stay_red(self): + for typ,reason in [('MissionStateStoreError','mission state transition FAILED -> CREATED is not permitted'),('RuntimeError','canonical runtime is busy'),('MissionStateStoreError','unrelated storage error')]: + self.assertFalse(module._concurrent_accepted([{'result':'STARTED'},{'result':'REJECTED','type':typ,'reason':reason}],1,1)) + self.assertFalse(module._concurrent_accepted([{'result':'STARTED'},{'result':'STARTED'}],1,1)) + self.assertFalse(module._concurrent_accepted([{'result':'REJECTED'},{'result':'REJECTED'}],1,1)) diff --git a/tests/test_projectless_scheduler.py b/tests/test_projectless_scheduler.py new file mode 100644 index 0000000..56617cc --- /dev/null +++ b/tests/test_projectless_scheduler.py @@ -0,0 +1,123 @@ +"""Installation idle is proven from canonical storage, never an EP-error fallback.""" +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import sqlite3 +import unittest +from unittest.mock import patch + +from forge.runtime import RuntimeBootstrap +from forge.server_runtime import ForgeServerRuntime +from forge.execution_host_configuration import PeerConfigurationError + + +class ProjectlessSchedulerTests(unittest.TestCase): + def setUp(self): + self.temp = TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name)/'instance' + RuntimeBootstrap(data_root=self.root, forge_version='test').open().close() + credential=self.root/'server-credential';credential.write_text('synthetic-server-token\n');credential.chmod(0o600) + self.server=ForgeServerRuntime(data_root=self.root,credential_file=credential,host='127.0.0.1',port=0) + self.addCleanup(self.server.server.server_close) + self.provider=patch.object(self.server.services,'provider_readiness',return_value={'ready':True}) + self.provider.start();self.addCleanup(self.provider.stop) + + def test_empty_projectless_instance_does_not_compose_execution_runtime(self): + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=AssertionError('project composition before work')) as opened: + self.server._tick() + opened.assert_not_called() + self.assertEqual(self.server.state.document()['scheduler']['state'],'IDLE') + readiness=self.server.services.installation_readiness() + self.assertTrue(readiness['service_ready']) + self.assertFalse(readiness['execution_ready']) + self.assertFalse(readiness['project_authorized']) + + def test_unavailable_provider_remains_blocking_even_when_empty(self): + self.provider.stop() + with patch.object(self.server.services,'provider_readiness',return_value={'ready':False}): + with self.assertRaisesRegex(RuntimeError,'provider is not ready'):self.server._tick() + + def test_nonempty_uncertain_work_evidence_cannot_take_empty_shortcut(self): + # An allocation with no admitted state is intentionally uncertain. + with sqlite3.connect(self.root/'forge.db') as db: + db.execute("INSERT INTO mission_id_allocations VALUES('synthetic-mission','2026-10-17','synthetic-only')") + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=PeerConfigurationError('project required')): + with self.assertRaises(PeerConfigurationError):self.server._tick() + + def test_corrupt_or_missing_canonical_storage_never_becomes_idle(self): + original=(self.root/'forge.db').read_bytes() + for fault in ('missing','corrupt'): + with self.subTest(fault=fault): + path=self.root/'forge.db';saved=self.root/'saved.db';path.rename(saved) + if fault=='corrupt':path.write_bytes(b'not sqlite') + try: + with self.assertRaises(Exception):self.server._tick() + self.assertNotEqual(self.server.state.document()['scheduler']['state'],'IDLE') + finally: + if path.exists():path.unlink() + saved.rename(path) + self.assertEqual((self.root/'forge.db').read_bytes(),original) + + def test_persisted_resumable_mission_retains_project_requirement(self): + from forge.state import MissionStateStore + db=RuntimeBootstrap(data_root=self.root,forge_version='test').open() + try: + state=MissionStateStore(db,data_root=str(self.root)).create( + {'id':'synthetic-resume'},[{'id':'intent'}],[{'id':'action'}],occurred_at='2026-10-17T00:00:00Z') + finally:db.close() + self.assertEqual(state.status.value,'CREATED') + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=PeerConfigurationError('project required')) as opened: + with self.assertRaises(PeerConfigurationError):self.server._tick() + opened.assert_called_once_with(str(self.root.resolve()),provider_id=self.server.provider_id) + + def test_workset_without_admission_cannot_appear_idle(self): + with sqlite3.connect(self.root/'forge.db') as db: + db.execute("INSERT INTO approved_worksets VALUES('synthetic-workset',1,'not-json')") + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=PeerConfigurationError('project required')): + with self.assertRaises(PeerConfigurationError):self.server._tick() + + def test_empty_probe_is_read_only_and_does_not_migrate(self): + from hashlib import sha256 + before=sha256((self.root/'forge.db').read_bytes()).hexdigest() + self.server._tick();self.server._tick() + self.assertEqual(sha256((self.root/'forge.db').read_bytes()).hexdigest(),before) + + def test_mutation_lease_contention_is_not_idle(self): + from forge.runtime.service import RuntimeServiceLock, RuntimeServiceBusy + with RuntimeServiceLock(self.root/'forge.db').acquire(): + with self.assertRaises(RuntimeServiceBusy):self.server._tick() + self.assertNotEqual(self.server.state.document()['scheduler']['state'],'IDLE') + + def test_nonempty_runtime_keeps_original_service_tick(self): + from contextlib import contextmanager + from forge.runtime.service import RuntimeServiceTick + with sqlite3.connect(self.root/'forge.db') as db: + db.execute("INSERT INTO mission_id_allocations VALUES('synthetic-mission','2026-10-17','synthetic-only')") + @contextmanager + def opened(*args,**kwargs):yield SimpleNamespace(states=object(),database=object()) + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=opened), patch('forge.server_runtime.ForgeRuntimeService') as service: + service.return_value.tick.return_value=RuntimeServiceTick(('synthetic-mission',),None,True) + self.server._tick() + service.return_value.tick.assert_called_once_with() + self.assertEqual(self.server.state.document()['scheduler']['state'],'READY') + + def test_orphan_dispatcher_evidence_cannot_take_idle_shortcut(self): + db=RuntimeBootstrap(data_root=self.root,forge_version='test').open() + try:db.save_dispatcher_state(status='ACTIVE',mission_sequence=('synthetic-mission',),active_mission_id='synthetic-mission') + finally:db.close() + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=PeerConfigurationError('project required')): + with self.assertRaises(PeerConfigurationError):self.server._tick() + + def test_unbound_execution_host_evidence_cannot_take_idle_shortcut(self): + with sqlite3.connect(self.root/'forge.db') as db: + db.execute("INSERT INTO execution_host_bindings VALUES('uncertain','not-json')") + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=PeerConfigurationError('project required')): + with self.assertRaises(PeerConfigurationError):self.server._tick() + + def test_unbound_durable_submission_audit_cannot_take_idle_shortcut(self): + db=RuntimeBootstrap(data_root=self.root,forge_version='test').open() + try:db.record_execution_host_exchange_audit('synthetic-uncertain',direction='FORGE_TO_EP',event_kind='FORGE_SUBMISSION_SENT',document={'status':'sent'}) + finally:db.close() + with patch('forge.server_runtime.InstalledDynamicMissionRuntime.open',side_effect=PeerConfigurationError('project required')): + with self.assertRaises(PeerConfigurationError):self.server._tick()