test(regression): add durable state lifecycle job

This commit is contained in:
huanghuoguoguo
2026-10-29 11:42:48 +08:00
parent f5924164d2
commit 34b9dbd6ea
6 changed files with 386 additions and 18 deletions
+23
View File
@@ -498,6 +498,29 @@ bind their own extensions to avoid changing unrelated prompts/tools. The suite a
even after failures. The CI owner then stops its service processes and removes
its Box containers; a process guard timeout can interrupt API cleanup.
## Durable state lifecycle job
Run the finite state job on the same disposable Linux stack:
```sh
uv run --no-sync python tools/regression_ci.py --suite state \
--lbctl /path/to/lbctl --plugins plugins --output .qa/state-001
```
The dependent chain creates a Pipeline, model bindings, HTTP Bot and isolated
knowledge base. It verifies conversation history and exact document content
through Runtime and Core restarts, changes the Pipeline model and replaces one
document while retaining a control document, then repeats the readback after
another Core restart. Deleting all documents must leave both file listings and
retrieval empty; normal Suite cleanup checks the owned configuration resources.
The business chain has a 270-second budget and a 30-second cleanup reserve;
plugin installation and initial service startup are outside this budget.
`gate/suite/cases.json` retains assertions, process replacement receipts, exact
HTTP statuses and retrieved content. A missing phase or failed assertion fails
the job. The job is opt-in and does not change Smoke or the daily schedule. It
checks graceful process restart durability, not abrupt power loss or migrations.
## Daily API contract job
`daily-endurance.yml` runs API contracts alongside the 30-minute endurance job
+25 -16
View File
@@ -21,7 +21,7 @@ from regression_suite import Suite
from regression_traffic import owned_box_containers, read_endpoint_snapshot
ROOT = Path(__file__).resolve().parents[1]
SUITE_BUDGETS = {'smoke': 60, 'scenarios': 300, 'daily': 1860, 'endurance': 900, 'api': 600}
SUITE_BUDGETS = {'smoke': 60, 'scenarios': 300, 'daily': 1860, 'endurance': 900, 'api': 600, 'state': 300}
sys.path.insert(0, str(ROOT))
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories # noqa: E402
@@ -34,6 +34,10 @@ def suite_profile(name, run_id, groups=(), api_seed=1):
profile = expanded_profile(groups)
else:
profile = json.loads(profile_path.read_text())
if name == 'state':
from regression_state import state_profile
profile = state_profile(profile)
if name == 'api':
from regression_api_cases import add_cases
@@ -309,21 +313,26 @@ def run(args):
)
generated = private / f'{args.suite}.json'
generated.write_text(json.dumps(profile, indent=2))
result = supervise(
[
sys.executable,
str(ROOT / 'tools/regression_suite.py'),
'run',
'--profile',
str(generated),
'--output',
str(root / 'gate/suite'),
],
root / 'gate',
SUITE_BUDGETS[args.suite] - restart_elapsed,
pids,
env,
)
if args.suite == 'state':
from regression_state import run_state_checks
result = run_state_checks(profile, profile_path, root / 'gate/suite', env, restart_owned)
else:
result = supervise(
[
sys.executable,
str(ROOT / 'tools/regression_suite.py'),
'run',
'--profile',
str(generated),
'--output',
str(root / 'gate/suite'),
],
root / 'gate',
SUITE_BUDGETS[args.suite] - restart_elapsed,
pids,
env,
)
evidence = [root / 'gate/suite']
if restart_result is not None:
evidence.append(root / 'restart-checks')
+15
View File
@@ -434,6 +434,21 @@
"priority": "P1",
"description": "Invalid MCP transport is rejected without changing stored configuration",
"bindings": [{"profile": "api", "case": "fixed_api_mcp", "checks": ["api_chain", "mcp_invalid_transport_preserved"]}, {"profile": "api", "case": "shuffled_api_mcp", "checks": ["api_chain", "mcp_invalid_transport_preserved"]}]
},
{"id": "state.configuration_persistence",
"area": "state", "kind": "blackbox", "priority": "P1",
"description": "Created and updated pipeline configuration survives owned Core and Runtime restarts",
"bindings": [{"profile": "state", "case": "durable_state_lifecycle", "checks": ["created_configuration", "after_runtime_configuration", "after_core_configuration", "updated_configuration", "persisted_update_configuration", "initial_runtime_process_replaced", "initial_core_process_replaced", "updated_core_process_replaced"]}]
},
{"id": "state.history_and_model_update",
"area": "state", "kind": "blackbox", "priority": "P1",
"description": "Conversation history survives restarts and the same session uses the updated model",
"bindings": [{"profile": "state", "case": "durable_state_lifecycle", "checks": ["created_chat_history", "after_runtime_chat_history", "after_core_chat_history", "updated_chat_history", "persisted_update_chat_history"]}]
},
{"id": "state.knowledge_replacement",
"area": "state", "kind": "blackbox", "priority": "P1",
"description": "Knowledge documents survive restarts; replacement preserves control content and deletion removes stale results",
"bindings": [{"profile": "state", "case": "durable_state_lifecycle", "checks": ["created_retrievable", "after_runtime_retrievable", "after_core_retrievable", "updated_retrievable", "updated_obsolete_absent", "persisted_update_retrievable", "persisted_update_obsolete_absent", "documents_deleted", "deleted_retrieval_empty"]}]
}
]
}
+278
View File
@@ -0,0 +1,278 @@
"""Finite durable-state lifecycle against the runner's disposable Core and Runtime."""
import copy
import json
from pathlib import Path
import secrets
import time
from regression_api_checks import check, request
from regression_coverage import checked
from regression_lifecycle_cases import after_id, completed
from regression_local_cases import require
from regression_smoke import reconcile_scenario_runs
from regression_suite import Suite, contains_fields, resolve, validate
from regression_traffic import group_request
def state_profile(profile):
"""Keep only the fixtures used by the dependent lifecycle chain."""
profile = copy.deepcopy(profile)
wanted = {
'mock_provider',
'embedding_provider',
'chat_model',
'unicode_model',
'embedding_model',
'empty_knowledge_base',
'chat_pipeline',
'chat_bot',
}
profile['resources'] = [item for item in profile['resources'] if item['id'] in wanted]
require({item['id'] for item in profile['resources']} == wanted, 'State lifecycle fixtures are incomplete')
profile.update(coverage_suite='state', cleanup='always')
profile['cases'] = [
{
'name': 'durable_state_lifecycle',
'stage': 'state',
'expect': 'LANGBOT_CHAT_OK',
'http_group': {
'url': '${core_base_url}/bots/${chat_bot}/sync',
'secret_env': 'LANGBOT_QA_GROUP_SECRET',
'message': 'ai Run the durable state scenario',
},
}
]
return profile
def document_visible(results, document, marker):
return any(
item.get('metadata', {}).get('file_id') == document['engine_document_id']
and marker in json.dumps(item.get('content', []), ensure_ascii=False)
for item in results
)
def document_absent(results, document, marker):
return all(
item.get('metadata', {}).get('file_id') != document['engine_document_id']
and marker not in json.dumps(item, ensure_ascii=False)
for item in results
)
def run_state_checks(profile, profile_path, output, env, restart_owned, budget=300):
"""Exercise create/restart/update/restart/delete while retaining exact business evidence."""
validate(profile)
output = Path(output)
output.mkdir(parents=True, exist_ok=False, mode=0o700)
started = time.monotonic()
deadline = started + budget - 30
suite = Suite(profile, profile_path, output, env)
def timeout(maximum):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('State lifecycle exhausted its execution deadline')
return min(maximum, remaining)
suite.timeout = timeout
suite.write('profile.json', profile)
row = {'case': 'durable_state_lifecycle', 'stage': 'state', 'status': 'unknown', 'restarts': []}
result = {'mode': 'state', 'cases': [row], 'errors': [], 'incomplete': [], 'phases': []}
def restart(service, phase):
receipt = restart_owned(service, timeout=timeout(60))
check(
row,
phase + '_process_replaced',
bool(receipt.get('old_pid')) and bool(receipt.get('new_pid')) and receipt['old_pid'] != receipt['new_pid'],
'Owned restart did not replace its process',
)
row['restarts'].append({'phase': phase, **receipt})
try:
suite.prepare()
row['after_id'] = after_id(suite)
case = resolve(profile['cases'][0], suite.variables)
pipeline_path = '/api/v1/pipelines/' + suite.variables['chat_pipeline']
kb_path = '/api/v1/knowledge/bases/' + suite.variables['empty_knowledge_base']
pipeline = request(suite, row, 'GET', pipeline_path)['pipeline']
submitted = resolve(
next(r for r in profile['resources'] if r['id'] == 'chat_pipeline')['body'], suite.variables
)
check(
row,
'created_configuration',
contains_fields(pipeline, submitted),
'Pipeline differs from created configuration',
)
expected_pipeline = {
key: pipeline[key] for key in ('uuid', 'name', 'description', 'config', 'extensions_preferences')
}
def configuration(phase):
current = request(suite, row, 'GET', pipeline_path)['pipeline']
check(
row,
phase + '_configuration',
contains_fields(current, expected_pipeline),
'Persisted pipeline configuration changed',
)
def files():
return request(suite, row, 'GET', kb_path + '/files')['files']
def ingest(label, marker):
previous = {item['uuid'] for item in files()}
document = output / (label + '.txt')
document.write_text(f'Durable lifecycle document {label}: {marker}.\n', encoding='utf-8')
suite.call(
[
'knowledge-base',
'ingest',
suite.variables['empty_knowledge_base'],
'--file',
str(document),
'--parser-plugin-id',
'langbot-team/GeneralParsers',
'--wait',
]
)
added = [item for item in files() if item['uuid'] not in previous]
check(
row,
label + '_ingested',
len(added) == 1 and added[0].get('status') == 'completed' and bool(added[0].get('engine_document_id')),
'Expected exactly one completed document',
)
row.setdefault('documents', {})[label] = {
'uuid': added[0]['uuid'],
'engine_document_id': added[0]['engine_document_id'],
'marker': marker,
}
return added[0]
markers = {name: name + secrets.token_hex(16) for name in ('original', 'control', 'replacement')}
documents = {name: ingest(name, markers[name]) for name in ('original', 'control')}
session, previous, turn = 'qa-state-' + secrets.token_hex(16), 'none', 0
def chat(phase):
nonlocal previous, turn
token = secrets.token_hex(16)
after = after_id(suite)
turn += 1
checked(
row,
phase + '_chat_history',
lambda: group_request(
case,
session,
'qa-state-member',
turn,
token,
previous,
suite.env,
timeout=timeout(30),
),
)
completed(suite, row, session, after)
previous = token
def retrieval(phase, *, updated=False):
found = request(
suite,
row,
'POST',
kb_path + '/retrieve',
{
'query': ' '.join(markers.values()),
'retrieval_settings': {'top_k': 10},
},
)['results']
check(
row,
phase + '_retrieval_shape',
isinstance(found, list)
and all(
isinstance(item, dict)
and isinstance(item.get('metadata'), dict)
and isinstance(item.get('content'), list)
for item in found
),
'Malformed retrieval response',
)
selected = ('replacement', 'control') if updated else ('original', 'control')
check(
row,
phase + '_retrievable',
all(document_visible(found, documents[name], markers[name]) for name in selected),
'Persisted documents are missing or return incorrect content',
)
if updated:
check(
row,
phase + '_obsolete_absent',
document_absent(found, documents['original'], markers['original'])
and documents['original']['uuid'] not in {item['uuid'] for item in files()},
'Replaced document is still visible through retrieval or file listing',
)
row.setdefault('retrievals', []).append({'phase': phase, 'results': found})
chat('created')
retrieval('created')
for service in ('runtime', 'core'):
restart(service, 'initial_' + service)
configuration('after_' + service)
chat('after_' + service)
retrieval('after_' + service)
updated = copy.deepcopy(expected_pipeline['config'])
updated['ai']['runner_config']['plugin:langbot-team/LocalAgent/default']['model']['primary'] = suite.variables[
'unicode_model'
]
request(suite, row, 'PUT', pipeline_path, {'description': 'Updated durable state fixture', 'config': updated})
expected_pipeline.update(description='Updated durable state fixture', config=updated)
configuration('updated')
case['expect'] = 'LANGBOT_UNICODE_OK'
case['http_group']['message'] = 'ai 请原样处理:你好,世界 🌍'
chat('updated')
documents['replacement'] = ingest('replacement', markers['replacement'])
request(suite, row, 'DELETE', kb_path + '/files/' + documents['original']['uuid'])
retrieval('updated', updated=True)
restart('core', 'updated_core')
configuration('persisted_update')
chat('persisted_update')
retrieval('persisted_update', updated=True)
for name in ('replacement', 'control'):
request(suite, row, 'DELETE', kb_path + '/files/' + documents[name]['uuid'])
check(row, 'documents_deleted', not files(), 'Deleted document metadata remains visible')
found = request(
suite,
row,
'POST',
kb_path + '/retrieve',
{
'query': ' '.join(markers.values()),
'retrieval_settings': {'top_k': 10},
},
)['results']
check(row, 'deleted_retrieval_empty', found == [], 'Deleted knowledge content remains retrievable')
reconcile_scenario_runs(suite, result, key='ledger_closeout')
require(not result['errors'], 'Lifecycle run ledger did not reconcile')
row['status'] = 'passed'
except Exception as exc:
row.update(status='unknown' if isinstance(exc, (TimeoutError, OSError)) else 'failed', error=str(exc))
result['errors'].append('state lifecycle: ' + str(exc))
if row['status'] == 'unknown':
result['incomplete'].append(result['errors'][-1])
finally:
row['seconds'] = time.monotonic() - started
suite.write('cases.json', result['cases'])
# Keep a bounded cleanup window even when the business chain reaches its deadline.
deadline = time.monotonic() + 30
suite.finish(result)
result.update(elapsed_seconds=time.monotonic() - started, case_count=1, requests=1)
suite.write('summary.json', result)
return result
+2 -2
View File
@@ -289,10 +289,10 @@ class Routes:
name = binding['profile']
if name not in profiles:
path = Path(__file__).with_name('regression_profiles') / f'{name}.json'
if name == 'api':
if name in ('api', 'state'):
from regression_ci import suite_profile
profile, _ = suite_profile('api', 'catalog-test')
profile, _ = suite_profile(name, 'catalog-test')
elif name == 'scenarios':
from regression_scenarios import expanded_profile
from regression_local_cases import DEFAULT_GROUPS
+43
View File
@@ -0,0 +1,43 @@
"""Challenge lifecycle retrieval oracles and keep the optional suite isolated."""
import copy
import unittest
from regression_ci import SUITE_BUDGETS, suite_profile
from regression_state import document_absent, document_visible
from regression_suite import validate
class StateLifecycleTest(unittest.TestCase):
def test_document_identity_and_content_are_both_required(self):
document = {'engine_document_id': 'original-file'}
original = {'metadata': {'file_id': 'original-file'}, 'content': [{'text': 'original-marker'}]}
self.assertTrue(document_visible([original], document, 'original-marker'))
self.assertFalse(document_visible([], document, 'original-marker'))
for changed in (
{'metadata': {'file_id': 'other-file'}, 'content': original['content']},
{'metadata': original['metadata'], 'content': [{'text': 'other-marker'}]},
):
with self.subTest(changed=changed):
self.assertFalse(document_visible([changed], document, 'original-marker'))
self.assertFalse(document_absent([changed], document, 'original-marker'))
self.assertFalse(document_absent([original], document, 'original-marker'))
self.assertTrue(document_absent([], document, 'original-marker'))
def test_state_profile_does_not_change_smoke_or_daily_cases(self):
smoke, _ = suite_profile('smoke', 'before')
daily, _ = suite_profile('daily', 'before')
expected = copy.deepcopy((smoke, daily))
state, _ = suite_profile('state', 'state')
validate(state)
self.assertEqual((smoke, daily), expected)
self.assertEqual(state['coverage_suite'], 'state')
self.assertNotIn('execution', state)
self.assertNotIn('scenario_groups', state)
self.assertEqual(SUITE_BUDGETS['state'], 300)
self.assertTrue(all(not resource.get('documents') for resource in state['resources']))
self.assertTrue(all('state' not in case['name'] for case in smoke['cases'] + daily['cases']))
if __name__ == '__main__':
unittest.main()