Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c54dc9c67 | ||
|
|
df78b26437 | ||
|
|
87a5fac40d | ||
|
|
08fe81efee | ||
|
|
9195470ceb | ||
|
|
cbe33ecab8 | ||
|
|
e3fc48e5b9 | ||
|
|
ed3bcf76c7 | ||
|
|
3cf9958cb9 | ||
|
|
6b70a33080 | ||
|
|
8d23692320 | ||
|
|
33d3947da6 | ||
|
|
806163b8ba | ||
|
|
13c2a18286 | ||
|
|
b8f10951ea | ||
|
|
2fcf0879c6 | ||
|
|
1136b4186d | ||
|
|
cca73ad52f | ||
|
|
424bf36c03 | ||
|
|
9eb00da0c4 | ||
|
|
9269e81997 | ||
|
|
6642820aa0 | ||
|
|
8374c6b1c7 | ||
|
|
c8a3251834 | ||
|
|
2418f56273 | ||
|
|
297582941f | ||
|
|
958a61cbc4 | ||
|
|
3096212919 | ||
|
|
aa2b091966 |
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||
"created_at": "2026-07-18T09:37:00+00:00",
|
||||
"schema_version": 3,
|
||||
"state_writer": "gateway",
|
||||
"work_units": [
|
||||
{
|
||||
"id": "R0-DISCOVER-SECRET-PROVIDER",
|
||||
"track": "R_RUNNER",
|
||||
"gate": "R0_RUNNER_LIVE_RECOVERY",
|
||||
"primary": "openclaw",
|
||||
"fallback": "codex",
|
||||
"status": "READY",
|
||||
"quorum": "requires_gpt_plus_one_executor"
|
||||
},
|
||||
{
|
||||
"id": "P2-INVENTORY-REAL-EVIDENCE",
|
||||
"track": "P_PERSONAL_WEIXIN",
|
||||
"gate": "P2_REAL_B3_EVIDENCE",
|
||||
"primary": "openclaw",
|
||||
"fallback": "codex",
|
||||
"status": "READY",
|
||||
"quorum": "requires_gpt_plus_one_executor"
|
||||
},
|
||||
{
|
||||
"id": "P1-POST-CONTRACT-REACK",
|
||||
"track": "P_PERSONAL_WEIXIN",
|
||||
"gate": "P1_REPAIR3_CODE",
|
||||
"primary": "codex",
|
||||
"fallback": "openclaw",
|
||||
"status": "READY",
|
||||
"quorum": "requires_gpt_plus_one_executor"
|
||||
},
|
||||
{
|
||||
"id": "Q0-GPT-HEALTH-INIT",
|
||||
"track": "_QUORUM",
|
||||
"gate": "Q0_GPT_AND_EXECUTOR_HEALTH",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "READY",
|
||||
"quorum": "gateway_initiated"
|
||||
},
|
||||
{
|
||||
"id": "Q1-ACTIVE-EXECUTOR-ELECTION",
|
||||
"track": "_QUORUM",
|
||||
"gate": "Q1_ELECT_ACTIVE_EXECUTOR",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "PENDING",
|
||||
"depends_on": "Q0-GPT-HEALTH-INIT",
|
||||
"quorum": "gateway_initiated"
|
||||
},
|
||||
{
|
||||
"id": "Q2-FAILOVER-HEALTHCHECK",
|
||||
"track": "_QUORUM",
|
||||
"gate": "Q2_STANDBY_HEALTH_MONITOR",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "PENDING",
|
||||
"depends_on": "Q1-ACTIVE-EXECUTOR-ELECTION",
|
||||
"quorum": "log_only_if_active_ok"
|
||||
},
|
||||
{
|
||||
"id": "Q3-UNATTENDED-ACCEPTANCE-MATRIX",
|
||||
"track": "_QUORUM",
|
||||
"gate": "A0_AUTOMATION_CLOSED",
|
||||
"primary": "gateway",
|
||||
"fallback": null,
|
||||
"status": "PENDING_LIVE_EVIDENCE",
|
||||
"depends_on": "Q2-FAILOVER-HEALTHCHECK",
|
||||
"quorum": "requires_gpt_plus_one_executor",
|
||||
"required_scenarios": 16,
|
||||
"may_mark_pass_without_live_evidence": false,
|
||||
"scenarios": [
|
||||
"GPT+OpenClaw-healthy-Codex-stopped-two-cycles",
|
||||
"GPT+Codex-healthy-OpenClaw-stopped-two-cycles",
|
||||
"Both-healthy-route-by-workload",
|
||||
"Active-stops-standby-auto-failover-no-duplicates",
|
||||
"Standby-alone-stopped-no-block",
|
||||
"GPT-stopped-block-even-with-both-executors",
|
||||
"GPT-healthy-both-executors-stopped-block",
|
||||
"UNKNOWN-never-counts-as-healthy",
|
||||
"HTTP-failure-then-file-polling-recovery",
|
||||
"Webhook-missed-then-git-ref-polling-recovery",
|
||||
"Gateway-restart-recovers-event-lease-outbox",
|
||||
"Same-event-id-no-redispatch",
|
||||
"Same-blocker-threshold-dead-letter",
|
||||
"Workspace-Agent-fallback-to-Responses-with-valid-credentials",
|
||||
"OpenClaw-offline-Codex-lifecycle-persisted",
|
||||
"Codex-offline-OpenClaw-lifecycle-persisted"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"adaptive_dispatch": {
|
||||
"codex_first": [
|
||||
"complex_code",
|
||||
"security_boundary",
|
||||
"architecture",
|
||||
"high_risk_review",
|
||||
"strong_model_materially_better"
|
||||
],
|
||||
"default_orchestrator": "openclaw",
|
||||
"dynamic_executor_selection": {
|
||||
"large_repetitive_infrastructure_monitoring": "openclaw",
|
||||
"complex_code_security_architecture": "codex",
|
||||
"both_healthy_active": "assign_by_preference",
|
||||
"one_healthy_active": "use_healthy_only",
|
||||
"both_unhealthy_blocked": true
|
||||
},
|
||||
"max_attempts_per_work_unit": 2,
|
||||
"on_timeout": [
|
||||
"mark_TIMEOUT",
|
||||
"preserve_logs",
|
||||
"switch_agent"
|
||||
],
|
||||
"openclaw_first": [
|
||||
"quality_close",
|
||||
"large_or_repetitive",
|
||||
"long_running",
|
||||
"infra_or_git_inventory",
|
||||
"cost_sensitive"
|
||||
],
|
||||
"single_state_writer_lease_required": true,
|
||||
"strict_role_partition": false,
|
||||
"state_writer": "gateway",
|
||||
"timeout_can_pass": false
|
||||
},
|
||||
"authoritative_state": {
|
||||
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||
"b4": "NOT_STARTED",
|
||||
"baseline": "V1R4C6",
|
||||
"production_release": "NOT_STARTED",
|
||||
"release": "paused",
|
||||
"repair3": {
|
||||
"base": "8d9c6da90fc8caa69b691d243c6fefb40b6b0575",
|
||||
"candidate": "PASS_CANDIDATE_PREPARED",
|
||||
"commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4",
|
||||
"exact4": "PASS",
|
||||
"result": "49a9c2e0cc898ecf2bbd45f454e007644d7f3045"
|
||||
},
|
||||
"runner": {
|
||||
"artifact_sha256": "85c472c05a1e55a5899b215d3cd41df53912787de529ac9cb3d5308da4158130",
|
||||
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
|
||||
"live": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"offline": "PASS",
|
||||
"result": "e789632e359e9232d8ae081e7c4f23ed7b615a9d"
|
||||
}
|
||||
},
|
||||
"central_gpt": {
|
||||
"required": "HEALTHY",
|
||||
"health_definition": {
|
||||
"live_probe_required": true,
|
||||
"workspace_agent_validated_live_or_responses_validated_live": true,
|
||||
"required_real_channels": true,
|
||||
"rejects": [
|
||||
"dummy_acceptance_key",
|
||||
"config_presence_only",
|
||||
"credential_presence_only"
|
||||
],
|
||||
"configuration_or_credential_presence_is_insufficient": true,
|
||||
"unknown_is_not_healthy": true,
|
||||
"no_health_without_channel": true
|
||||
},
|
||||
"independent_review": {
|
||||
"single_executor_mode": true,
|
||||
"required_evidence": [
|
||||
"committed_git_tree",
|
||||
"full_commit_oid",
|
||||
"test_logs_with_rc",
|
||||
"gitleaks",
|
||||
"artifact_digest",
|
||||
"worktree_bare_gitea_oid",
|
||||
"live_runtime_evidence"
|
||||
],
|
||||
"model_judgment_insufficient": true,
|
||||
"both_executors_healthy_extra_reviewer_available": true,
|
||||
"extra_reviewer_not_mandatory_gate": true
|
||||
}
|
||||
},
|
||||
"contract_id": "V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT",
|
||||
"executor_quorum": {
|
||||
"minimum_healthy": 1,
|
||||
"candidates": [
|
||||
"openclaw",
|
||||
"codex"
|
||||
],
|
||||
"quorum_pass": [
|
||||
"GPT_HEALTHY_AND_OPENCLAW_HEALTHY",
|
||||
"GPT_HEALTHY_AND_CODEX_HEALTHY"
|
||||
],
|
||||
"quorum_block": [
|
||||
"GPT_UNHEALTHY",
|
||||
"GPT_HEALTHY_BUT_BOTH_EXECUTORS_UNAVAILABLE",
|
||||
"GPT_OR_SOLE_EXECUTOR_UNKNOWN"
|
||||
]
|
||||
},
|
||||
"gateway_lifecycle_events": [
|
||||
"TASK_DISPATCHED",
|
||||
"TASK_STARTED",
|
||||
"TASK_ENDED",
|
||||
"DECISION_APPLIED",
|
||||
"PROJECT_SUMMARY",
|
||||
"PROJECT_COMPLETED"
|
||||
],
|
||||
"facts_policy": {
|
||||
"accept": [
|
||||
"committed_git_tree",
|
||||
"full_oid",
|
||||
"worktree_bare_gitea_oid_match",
|
||||
"test_log_with_rc",
|
||||
"immutable_digest",
|
||||
"result_only_commit",
|
||||
"live_evidence"
|
||||
],
|
||||
"reject": [
|
||||
"verbal_claim",
|
||||
"readme_only",
|
||||
"local_only_commit",
|
||||
"environment_variable_as_secret_ref",
|
||||
"compile_only",
|
||||
"timeout_as_pass",
|
||||
"unrelated_repo",
|
||||
"fabricated_evidence"
|
||||
]
|
||||
},
|
||||
"forbidden_git": [
|
||||
"reset",
|
||||
"amend",
|
||||
"rebase",
|
||||
"force_push",
|
||||
"direct_main_push",
|
||||
"direct_bare_ref_mutation",
|
||||
"unapproved_gitea_db_mutation",
|
||||
"reuse_accident_worktree",
|
||||
"delete_live_worktree_to_clear_collision"
|
||||
],
|
||||
"generated_at": "2026-07-18T09:37:00+00:00",
|
||||
"handoff": {
|
||||
"ack": "CONTRACT_ACK=<full_contract_commit_oid>",
|
||||
"advance": "independent_verification_required",
|
||||
"all_agents_match": true
|
||||
},
|
||||
"join_gate": {
|
||||
"requires": [
|
||||
"AUTOMATION_CLOSED",
|
||||
"RUNNER_RECOVERED",
|
||||
"B3_PASS",
|
||||
"B4_PASS",
|
||||
"INTEGRATION_PASS"
|
||||
]
|
||||
},
|
||||
"model_policy": {
|
||||
"codex": "strongest_model_actually_available_in_runtime",
|
||||
"gpt_5_6": "use_only_if_exposed",
|
||||
"no_unverified_model_claims": true,
|
||||
"openclaw_default": "DeepSeek V4 Flash"
|
||||
},
|
||||
"owner_authorization": {
|
||||
"adaptive_agent_assignment_authorized": true,
|
||||
"agent_substitution_authorized": true,
|
||||
"autonomous_execution": true,
|
||||
"may_bypass_release_controls": false,
|
||||
"may_bypass_secret_controls": false,
|
||||
"may_fabricate_evidence": false,
|
||||
"user_step_by_step_confirmation_required": false
|
||||
},
|
||||
"release_gates": [
|
||||
"AUTOMATION_CLOSED",
|
||||
"MAIN_MERGE",
|
||||
"STAGING_AND_CANARY",
|
||||
"PRODUCTION"
|
||||
],
|
||||
"schema_version": 4,
|
||||
"self_healing": {
|
||||
"priorities": {
|
||||
"p0": "central_gpt_failure",
|
||||
"p1": "both_executors_unavailable",
|
||||
"p2": "active_executor_failed_with_healthy_standby",
|
||||
"p3": "standby_executor_alone_failed"
|
||||
},
|
||||
"rules": {
|
||||
"standby_failure_when_active_ok": {
|
||||
"action": "log_only",
|
||||
"block_business": false,
|
||||
"create_blocking_gate": false,
|
||||
"pause_release": false
|
||||
},
|
||||
"active_executor_failure": {
|
||||
"action": "failover_to_standby",
|
||||
"inherit_contract_commit": true,
|
||||
"inherit_event_id": true,
|
||||
"inherit_state_version": true,
|
||||
"mark_original_task": "INTERRUPTED_AGENT_FAILOVER",
|
||||
"new_executor_continues_from_checkpoint": true,
|
||||
"block_duplicate_side_effects": true
|
||||
},
|
||||
"both_unavailable_block": "BLOCKED_NO_HEALTHY_EXECUTOR"
|
||||
}
|
||||
},
|
||||
"state_rules": [
|
||||
"tracks_run_parallel",
|
||||
"runner_block_does_not_block_offline_work_or_evidence_inventory",
|
||||
"join_requires_both_tracks",
|
||||
"failure_stops_dependents",
|
||||
"production_remains_V1R4C6_paused_until_production_pass",
|
||||
"gateway_persists_authoritative_state",
|
||||
"lifecycle_events_persisted_regardless_of_openclaw_online"
|
||||
],
|
||||
"status": "ACTIVE",
|
||||
"tracks": {
|
||||
"P_PERSONAL_WEIXIN": {
|
||||
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||
"b4": "NOT_STARTED",
|
||||
"parallel": true,
|
||||
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||
"repair3_commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4"
|
||||
},
|
||||
"R_RUNNER": {
|
||||
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
|
||||
"current": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"fallback": "codex",
|
||||
"parallel": true,
|
||||
"primary": "openclaw",
|
||||
"required": [
|
||||
"artifact_verified",
|
||||
"approved_secret_provider_contract",
|
||||
"owner_probe_verified",
|
||||
"service_healthy",
|
||||
"heartbeat_healthy",
|
||||
"gateway_auth",
|
||||
"durable_task_create_read_cancel",
|
||||
"execution_channel",
|
||||
"live_rollback"
|
||||
]
|
||||
}
|
||||
},
|
||||
"automation_closed": {
|
||||
"success_criteria": {
|
||||
"CENTRAL_GPT_HEALTH": "PASS",
|
||||
"EXECUTOR_QUORUM": "PASS",
|
||||
"ACTIVE_EXECUTOR": "openclaw|codex",
|
||||
"EVENT_TRIGGER": "PASS",
|
||||
"POLLING": "PASS",
|
||||
"FAILOVER": "PASS",
|
||||
"LIFECYCLE_SYNC": "PASS",
|
||||
"TWO_CYCLE_UNATTENDED_LOOP": "PASS",
|
||||
"RESTART_RECOVERY": "PASS",
|
||||
"AUTOMATION_CLOSED": "PASS"
|
||||
},
|
||||
"no_longer_require": [
|
||||
"OPENCLAW_AUTO_CLAIM=PASS",
|
||||
"CODEX_AUTO_CLAIM=PASS"
|
||||
],
|
||||
"only_current_active_executor_claim_required": true
|
||||
},
|
||||
"lifecycle_ownership": {
|
||||
"authoritative_persister": "gateway",
|
||||
"openclaw_online_mirror": true,
|
||||
"openclaw_offline_behavior": "gateway_persists_directly"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
# V2R1 Gate 1 — Clean-Room Final Evidence
|
||||
|
||||
## Summary
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Date | 2026-07-14 |
|
||||
| Source commit | `502ef30642597fb0b70b90ae2e8a0b6af217d80e` |
|
||||
| Source branch | `origin/work/v2r1-integration-quality-2000` |
|
||||
| Result branch | `fix/v2r1-g1-cleanroom-final-evidence-r2-20260714` |
|
||||
| Result commit reference | `HEAD` |
|
||||
| Gate | `G1` |
|
||||
| Variant | `openclaw-cleanroom` |
|
||||
| Status | `PASS` |
|
||||
|
||||
The immutable report uses `HEAD` for post-commit verification. The resolved commit object ID is recorded by the external acceptance result after this file is committed; a Git commit cannot reliably embed its own object ID because changing the file changes the tree and commit identity.
|
||||
|
||||
## Machine-Readable Result
|
||||
|
||||
```yaml
|
||||
gate: G1
|
||||
variant: openclaw-cleanroom
|
||||
date: 2026-07-14
|
||||
status: PASS
|
||||
source_commit: 502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||
source_branch: origin/work/v2r1-integration-quality-2000
|
||||
result_branch: fix/v2r1-g1-cleanroom-final-evidence-r2-20260714
|
||||
result_commit_ref: HEAD
|
||||
tests_total: 32
|
||||
tests_passed: 32
|
||||
tests_failed: 0
|
||||
cleanup_scan: PASS
|
||||
secret_scan: PASS
|
||||
rollback_status: PASS
|
||||
recovery_verified: true
|
||||
workspace_clean_at_test_completion: true
|
||||
merge_performed: false
|
||||
tag_created: false
|
||||
baseline_promoted: false
|
||||
deployment_performed: false
|
||||
```
|
||||
|
||||
## Evidence Provenance
|
||||
|
||||
The test, cleanup, secret-scan, rollback, and recovery results below are execution evidence supplied by OpenClaw for the Gate 1 run. This correction commit changes only the evidence report and does not claim that the GitOps API reran the suites.
|
||||
|
||||
## HMAC Test-Key Length Verification
|
||||
|
||||
| Test file | Variable | Test-only value | Length |
|
||||
|---|---|---|---:|
|
||||
| `tests/v2r1/test_runtime_wiring.py:24` | `internal_key` | `b"runtime-internal-context-key-is-32bytes"` | 39 bytes |
|
||||
| `tests/v2r1/test_runtime_wiring.py:25` | `ingress_key` | `b"runtime-external-ingress-key-is-32bytes"` | 39 bytes |
|
||||
|
||||
Both values are test fixtures. The supplied evidence reports that no production secret was introduced.
|
||||
|
||||
## Test Results Supplied by OpenClaw
|
||||
|
||||
### V2R1 suite — 29 of 29 passed
|
||||
|
||||
```shell
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest \
|
||||
tests/v2r1/test_hermes_fabric.py \
|
||||
tests/v2r1/test_message_ledger_append.py \
|
||||
tests/v2r1/test_router_isolation.py \
|
||||
tests/v2r1/test_runtime_identity_binding.py \
|
||||
tests/v2r1/test_runtime_wiring.py \
|
||||
tests/v2r1/test_tenant_context.py -v
|
||||
# Reported result: 29 passed, exit code 0
|
||||
```
|
||||
|
||||
### Integration suite — 3 of 3 passed
|
||||
|
||||
```shell
|
||||
PYTHONPATH=active-runtime/app python3 -m pytest tests/integration/test_runtime_ingress_flow.py -v
|
||||
# Reported result: 3 passed, exit code 0
|
||||
```
|
||||
|
||||
**Reported total: 32 of 32 passed.**
|
||||
|
||||
## Cleanup, Security, and Recovery Evidence
|
||||
|
||||
| Check | Supplied result |
|
||||
|---|---|
|
||||
| Conflict-marker scan | PASS |
|
||||
| Cleanup scan | PASS |
|
||||
| Secret scan | PASS |
|
||||
| Legitimate hardcoded test keys | Two 39-byte fixtures in `test_runtime_wiring.py` |
|
||||
| Rollback rehearsal | PASS; 13 of 13 core tests reported after rollback to `6077331` |
|
||||
| Recovery verification | PASS |
|
||||
|
||||
## Deployment State
|
||||
|
||||
| Operation | Performed |
|
||||
|---|---|
|
||||
| Merge | No |
|
||||
| Tag creation | No |
|
||||
| Baseline promotion | No |
|
||||
| Staging deployment | No |
|
||||
| Production deployment | No |
|
||||
|
||||
## Post-Commit Verification
|
||||
|
||||
Run from the repository root after the commit is created:
|
||||
|
||||
```shell
|
||||
set -eu
|
||||
|
||||
SOURCE=502ef30642597fb0b70b90ae2e8a0b6af217d80e
|
||||
RESULT_FILE=.agent/results/openclaw/RESULT-V2R1-G1-OPENCLAW-FINAL-EVIDENCE-20260714.md
|
||||
|
||||
git rev-parse HEAD
|
||||
|
||||
test "$(git diff --name-only "$SOURCE" HEAD | wc -l | tr -d ' ')" = "1"
|
||||
test "$(git diff --name-only "$SOURCE" HEAD)" = "$RESULT_FILE"
|
||||
|
||||
git ls-tree -r HEAD -- "$RESULT_FILE"
|
||||
|
||||
python3 - "$RESULT_FILE" <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
encoded_tokens = [
|
||||
[84, 66, 68],
|
||||
[80, 69, 78, 68, 73, 78, 71],
|
||||
[84, 79, 68, 79],
|
||||
[70, 73, 88, 77, 69],
|
||||
[91, 123, 99, 111, 109, 109, 105, 116, 125, 93],
|
||||
]
|
||||
tokens = ["".join(chr(value) for value in item) for item in encoded_tokens]
|
||||
angle_token = re.compile(chr(60) + r"[^>\n]+" + chr(62))
|
||||
|
||||
hits = [token for token in tokens if token in text]
|
||||
if angle_token.search(text):
|
||||
hits.append("angle-delimited-token")
|
||||
|
||||
if hits:
|
||||
print("TOKEN_SCAN=FAIL", hits)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("TOKEN_SCAN=PASS")
|
||||
PY
|
||||
```
|
||||
|
||||
## Acceptance Conditions
|
||||
|
||||
Gate 1 is eligible to proceed only when the external acceptance record confirms all of the following against the resolved `HEAD` commit:
|
||||
|
||||
- the commit exists and has source commit `502ef30642597fb0b70b90ae2e8a0b6af217d80e` as its parent;
|
||||
- exactly this one report file changed;
|
||||
- the committed report contains no unresolved legacy token;
|
||||
- the result tree is not identical to rejected commit `71239b4d187e683e05fd22fc9d5df8ae324e3390`;
|
||||
- no merge, tag, baseline promotion, or deployment occurred.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||
"b4": "NOT_STARTED",
|
||||
"baseline": "V1R4C6",
|
||||
"contract_branch": "task/governance/v2r1-personal-weixin-production-contract-20260717",
|
||||
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||
"dispatch_commit": null,
|
||||
"executor_quorum": {
|
||||
"candidates": [
|
||||
"openclaw",
|
||||
"codex"
|
||||
],
|
||||
"minimum_healthy": 1,
|
||||
"openclaw": "UNKNOWN",
|
||||
"codex": "UNKNOWN"
|
||||
},
|
||||
"active_executor": null,
|
||||
"standby_executor": null,
|
||||
"central_gpt": "UNKNOWN",
|
||||
"automation_closed": {
|
||||
"CENTRAL_GPT_HEALTH": "UNKNOWN",
|
||||
"EXECUTOR_QUORUM": "UNKNOWN",
|
||||
"ACTIVE_EXECUTOR": null,
|
||||
"EVENT_TRIGGER": "UNKNOWN",
|
||||
"POLLING": "UNKNOWN",
|
||||
"FAILOVER": "UNKNOWN",
|
||||
"LIFECYCLE_SYNC": "UNKNOWN",
|
||||
"TWO_CYCLE_UNATTENDED_LOOP": "UNKNOWN",
|
||||
"RESTART_RECOVERY": "UNKNOWN",
|
||||
"result": "NOT_STARTED"
|
||||
},
|
||||
"lifecycle_events": [],
|
||||
"production_release": "NOT_STARTED",
|
||||
"release": "paused",
|
||||
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||
"runner": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
"schema_version": 3,
|
||||
"single_state_writer": "gateway",
|
||||
"quorum_controller": {
|
||||
"implementation": "CODE_COMMITTED_TESTED_PENDING_LIVE_ACCEPTANCE",
|
||||
"live_acceptance": "NOT_STARTED",
|
||||
"health_source": "ROOT_OWNED_METADATA_ONLY_LIVE_PROBE_RECEIPTS",
|
||||
"unknown_counts_as_healthy": false
|
||||
},
|
||||
"updated_at": "2026-07-18T09:37:00+00:00",
|
||||
"gateway_persists_authoritative_state": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Codex V2R1 adaptive execution
|
||||
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
|
||||
Execute P1-POST-CONTRACT-REACK now:
|
||||
- read-only verify Repair3 e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||
- read-only verify RESULT 49a9c2e0cc898ecf2bbd45f454e007644d7f3045
|
||||
- verify lineage from 8d9c6da90fc8caa69b691d243c6fefb40b6b0575, exact4, tests, Gitleaks and RESULT-only scope
|
||||
- do not modify Repair3 code in this unit
|
||||
Act as fallback for OpenClaw only on timeout/unavailability. Use GPT-5.6 only if actually exposed; otherwise strongest visible model. Prefer OpenClaw when quality is comparable or volume is high; prefer Codex for complex/security-critical work.
|
||||
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
|
||||
Report:
|
||||
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=codex STATUS=<status> IMPLEMENTATION=<oid-or-NONE> RESULT=<oid-or-NONE> OID_MATCH=<YES-or-NO> BLOCKER=<reason-or-NONE>
|
||||
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# OpenClaw A0 Three-Party Self-Healing and Release Supervision
|
||||
|
||||
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
|
||||
## Mission
|
||||
|
||||
Close the GPT/OpenClaw/Codex unattended loop before any main merge or production release. OpenClaw is the default state writer and long-running supervisor, but work may be reassigned dynamically under the shared contract.
|
||||
|
||||
## Authoritative inputs
|
||||
|
||||
- Governance contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||
- Governance dispatch head before this task: `9195470ceb78fd57ffface3dcc38f40b75efa9d8`
|
||||
- Gateway integration implementation: `c3984ca2b4388875d2875b4fdfdf95a2984f6ab3`
|
||||
- Gateway integration RESULT: `4e63b1559aa3fc07b1384154c97b2c90b84353ed`
|
||||
- Three-party self-healer candidate branch: `patch/a0-three-party-self-healing-20260718-20260718T043256`
|
||||
- Production baseline: `V1R4C6`
|
||||
- Release status: `paused`
|
||||
|
||||
## Priority invariant
|
||||
|
||||
Continuously evaluate exactly three parties:
|
||||
|
||||
1. `central_gpt` — Orchestrator Gateway plus a valid Workspace Agent or Responses API decision channel.
|
||||
2. `openclaw` — OpenClaw watcher, lifecycle sync, durable task execution and heartbeat.
|
||||
3. `codex` — Codex watcher, auto-claim, lifecycle sync, durable task execution and heartbeat.
|
||||
|
||||
When exactly two parties are HEALTHY and the third is explicitly UNHEALTHY, create a P0 repair work unit for the unique unhealthy party before every other task. UNKNOWN is not UNHEALTHY and must fail closed. Do not repair when fewer than two are healthy or any health result is ambiguous.
|
||||
|
||||
## A0-SH1: supervise implementation completion
|
||||
|
||||
Codex owns the high-risk code changes. Independently verify that the final implementation contains:
|
||||
|
||||
- strict root-owned configuration validation and no symlink chain;
|
||||
- fixed built-in party adapters rather than arbitrary untrusted repair commands;
|
||||
- bounded stdout/stderr redaction so tokens and secrets cannot enter audit logs;
|
||||
- exclusive lease, cooldown, circuit breaker and stale lease recovery;
|
||||
- event trigger plus periodic polling;
|
||||
- OpenClaw lifecycle events: `TASK_DISPATCHED`, `TASK_STARTED`, `TASK_ENDED`, `DECISION_APPLIED`, `PROJECT_SUMMARY`, `PROJECT_COMPLETED`;
|
||||
- durable file outbox fallback when HTTP callback is unavailable;
|
||||
- systemd service and timer;
|
||||
- health and repair adapters for central GPT, OpenClaw and Codex;
|
||||
- install, verify and rollback scripts;
|
||||
- deterministic tests for all health combinations and failures.
|
||||
|
||||
Reject the current candidate if it only contains `automation/three_party_self_healer.py` without these controls.
|
||||
|
||||
## A0-SH2: live three-party validation
|
||||
|
||||
After Codex implementation RESULT passes, deploy only to a non-production acceptance context and run:
|
||||
|
||||
1. All-three healthy steady state for at least three poll intervals.
|
||||
2. GPT unhealthy while OpenClaw and Codex healthy: GPT repair becomes P0 and recovers.
|
||||
3. OpenClaw unhealthy while GPT and Codex healthy: OpenClaw repair becomes P0 and recovers.
|
||||
4. Codex unhealthy while GPT and OpenClaw healthy: Codex repair becomes P0 and recovers.
|
||||
5. One healthy plus two unhealthy: no repair; fail closed.
|
||||
6. Any UNKNOWN health: no repair; fail closed.
|
||||
7. Repair timeout: retry with backoff, then circuit open.
|
||||
8. Service restart: state, lease and pending work recover.
|
||||
9. HTTP event path unavailable: file inbox polling recovers.
|
||||
10. Git webhook unavailable: Git ref polling detects change.
|
||||
11. Duplicate event ID: no duplicate dispatch.
|
||||
12. Main Workspace Agent channel unavailable: Responses fallback takes over only with a valid approved credential.
|
||||
13. OpenClaw receives every task start/end and project summary notification.
|
||||
14. Two consecutive unattended decision/execution/result cycles complete with no chat message or manual dispatch.
|
||||
|
||||
Do not use dummy API keys or environment-variable presence as proof of a valid central decision channel.
|
||||
|
||||
## A0-SH3: authoritative closure
|
||||
|
||||
Only after independent verification of the above create one append-only state update and an exact-one RESULT file. Required output:
|
||||
|
||||
```text
|
||||
AUTOMATION_CLOSED=PASS
|
||||
CENTRAL_GPT_HEALTH=PASS
|
||||
OPENCLAW_AUTO_CLAIM=PASS
|
||||
CODEX_AUTO_CLAIM=PASS
|
||||
THREE_PARTY_SELF_HEALING=PASS
|
||||
EVENT_TRIGGER=PASS
|
||||
POLLING=PASS
|
||||
OPENCLAW_LIFECYCLE_SYNC=PASS
|
||||
TWO_CYCLE_UNATTENDED_LOOP=PASS
|
||||
RESTART_RECOVERY=PASS
|
||||
ROLLBACK=PASS
|
||||
```
|
||||
|
||||
Any missing item keeps `AUTOMATION_CLOSED=NO`.
|
||||
|
||||
## Release continuation
|
||||
|
||||
After and only after `AUTOMATION_CLOSED=PASS`, automatically resume the governed release sequence:
|
||||
|
||||
1. Real B3 evidence intake and independent review.
|
||||
2. B4 implementation and validation.
|
||||
3. Full integration, regression, security and migration tests.
|
||||
4. Normal PR/GitOps merge to main; never direct-push main.
|
||||
5. Immutable release artifact, digest, SBOM and signature.
|
||||
6. Staging and rollback exercise.
|
||||
7. Canary and monitoring validation.
|
||||
8. Owner/admin GitOps production deployment using existing approved release controls.
|
||||
9. Production smoke tests and production RESULT-only commit.
|
||||
|
||||
External facts that cannot be fabricated — real WeChat credentials/raw-wire, valid secret references and owner-only release approval — remain fail-closed blockers. The loop must monitor them and resume automatically when they become available.
|
||||
|
||||
## Forbidden
|
||||
|
||||
- reset, amend, rebase, force push;
|
||||
- direct push to main;
|
||||
- controller mutation;
|
||||
- secret disclosure;
|
||||
- dummy credentials presented as production credentials;
|
||||
- fabricated B3 evidence;
|
||||
- timeout or skipped checks recorded as PASS;
|
||||
- production release before `AUTOMATION_CLOSED=PASS`.
|
||||
|
||||
Progress format:
|
||||
|
||||
```text
|
||||
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<stable-reason-or-NONE>
|
||||
```
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# OpenClaw V2R1 adaptive orchestration
|
||||
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
|
||||
You are default orchestrator and sole authoritative state writer.
|
||||
Execute immediately:
|
||||
1. R0-DISCOVER-SECRET-PROVIDER: discover approved provider metadata and owner-probe source without reading or outputting secrets. Environment variables alone are not approved secret refs.
|
||||
2. P2-INVENTORY-REAL-EVIDENCE: inventory approved restricted B3 evidence locations; validate existing facts; never synthesize evidence.
|
||||
3. Independently verify Codex results before advancing.
|
||||
Prefer OpenClaw for comparable-quality, large, repetitive, long-running, infrastructure and cost-sensitive work. Prefer Codex strongest actually available model for complex code, security boundaries, architecture and high-risk review. On timeout mark TIMEOUT and switch agent; never infer PASS.
|
||||
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
|
||||
Report:
|
||||
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<reason-or-NONE>
|
||||
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
|
||||
Executable
+820
@@ -0,0 +1,820 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable GPT + one-executor quorum and failover controller.
|
||||
|
||||
The controller persists decisions before dispatch and never stores credentials.
|
||||
Health is accepted only from metadata-only, live-probe receipts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
|
||||
ALLOWED_LIFECYCLE_EVENTS = frozenset(
|
||||
{
|
||||
"TASK_DISPATCHED",
|
||||
"TASK_STARTED",
|
||||
"TASK_ENDED",
|
||||
"DECISION_APPLIED",
|
||||
"PROJECT_SUMMARY",
|
||||
"PROJECT_COMPLETED",
|
||||
}
|
||||
)
|
||||
EXECUTORS = ("openclaw", "codex")
|
||||
SECRET_KEYS = frozenset(
|
||||
{
|
||||
"secret",
|
||||
"token",
|
||||
"password",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"credential",
|
||||
"private_key",
|
||||
"environment",
|
||||
"env",
|
||||
"argv",
|
||||
"stdout",
|
||||
"stderr",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Health(str, Enum):
|
||||
HEALTHY = "HEALTHY"
|
||||
UNHEALTHY = "UNHEALTHY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ControllerError(RuntimeError):
|
||||
"""Fail-closed controller error."""
|
||||
|
||||
|
||||
class EventConflict(ControllerError):
|
||||
"""An event id was reused with a different payload."""
|
||||
|
||||
|
||||
class StaleStateVersion(ControllerError):
|
||||
"""A dispatch attempted to overwrite a newer task state."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuorumDecision:
|
||||
allowed: bool
|
||||
priority: str
|
||||
status: str
|
||||
active_executor: str | None
|
||||
standby_executor: str | None
|
||||
repair_required: bool
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _contains_forbidden_key(value: Any) -> bool:
|
||||
if isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
normalized = str(key).strip().lower().replace("-", "_")
|
||||
if normalized in SECRET_KEYS or _contains_forbidden_key(child):
|
||||
return True
|
||||
elif isinstance(value, list):
|
||||
return any(_contains_forbidden_key(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def validate_metadata_only(value: Mapping[str, Any]) -> None:
|
||||
if _contains_forbidden_key(value):
|
||||
raise ControllerError("secret-bearing or output-bearing fields are forbidden")
|
||||
|
||||
|
||||
def atomic_json_write(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
validate_secure_directory(path.parent)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(canonical_json(value))
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def validate_secure_directory(path: Path, *, expected_uid: int | None = None) -> None:
|
||||
info = path.lstat()
|
||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
||||
raise ControllerError(f"unsafe directory type rejected: {path}")
|
||||
if os.name == "posix" and path.resolve(strict=True) != Path(os.path.abspath(path)):
|
||||
raise ControllerError(f"symlinked directory component rejected: {path}")
|
||||
if os.name == "posix" and info.st_mode & 0o022:
|
||||
raise ControllerError(f"group/world-writable directory rejected: {path}")
|
||||
if expected_uid is not None and info.st_uid != expected_uid:
|
||||
raise ControllerError(f"unexpected owner for directory {path}")
|
||||
|
||||
|
||||
def read_secure_json(path: Path, *, expected_uid: int | None = None) -> dict[str, Any]:
|
||||
info = path.lstat()
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise ControllerError(f"symlink rejected: {path}")
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ControllerError(f"not a regular file: {path}")
|
||||
if os.name == "posix" and info.st_mode & 0o022:
|
||||
raise ControllerError(f"group/world-writable file rejected: {path}")
|
||||
validate_secure_directory(path.parent, expected_uid=expected_uid)
|
||||
if os.name == "posix" and path.resolve(strict=True) != Path(os.path.abspath(path)):
|
||||
raise ControllerError(f"symlinked file component rejected: {path}")
|
||||
if expected_uid is not None and info.st_uid != expected_uid:
|
||||
raise ControllerError(f"unexpected owner for {path}")
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ControllerError(f"JSON object required: {path}")
|
||||
validate_metadata_only(value)
|
||||
return value
|
||||
|
||||
|
||||
def validate_health_receipt(
|
||||
receipt: Mapping[str, Any],
|
||||
subject: str,
|
||||
*,
|
||||
now: float | None = None,
|
||||
max_age_seconds: int = 60,
|
||||
) -> Health:
|
||||
validate_metadata_only(receipt)
|
||||
if receipt.get("subject") != subject:
|
||||
return Health.UNKNOWN
|
||||
try:
|
||||
health = Health(str(receipt.get("health", "UNKNOWN")))
|
||||
except ValueError:
|
||||
return Health.UNKNOWN
|
||||
if health is not Health.HEALTHY:
|
||||
return health
|
||||
if receipt.get("live_probe_succeeded") is not True:
|
||||
return Health.UNKNOWN
|
||||
observed_at = receipt.get("observed_at_epoch")
|
||||
if not isinstance(observed_at, (int, float)):
|
||||
return Health.UNKNOWN
|
||||
now = time.time() if now is None else now
|
||||
if observed_at > now + 5 or now - observed_at > max_age_seconds:
|
||||
return Health.UNKNOWN
|
||||
if subject == "central_gpt":
|
||||
workspace = receipt.get("workspace_agent")
|
||||
responses = receipt.get("responses_api")
|
||||
workspace_ok = isinstance(workspace, Mapping) and workspace.get("validated_live") is True
|
||||
responses_ok = isinstance(responses, Mapping) and responses.get("validated_live") is True
|
||||
if not (workspace_ok or responses_ok):
|
||||
return Health.UNKNOWN
|
||||
return Health.HEALTHY
|
||||
|
||||
|
||||
def choose_executor(workload: str) -> str:
|
||||
if workload in {"complex_code", "security_boundary", "architecture", "complex_code_security_architecture"}:
|
||||
return "codex"
|
||||
return "openclaw"
|
||||
|
||||
|
||||
def evaluate_quorum(
|
||||
central_gpt: Health,
|
||||
openclaw: Health,
|
||||
codex: Health,
|
||||
workload: str,
|
||||
) -> QuorumDecision:
|
||||
if central_gpt is not Health.HEALTHY:
|
||||
status = "BLOCKED_CENTRAL_GPT_UNKNOWN" if central_gpt is Health.UNKNOWN else "BLOCKED_CENTRAL_GPT_UNHEALTHY"
|
||||
return QuorumDecision(False, "P0", status, None, None, True)
|
||||
|
||||
healthy = [name for name, health in (("openclaw", openclaw), ("codex", codex)) if health is Health.HEALTHY]
|
||||
if not healthy:
|
||||
has_unknown = Health.UNKNOWN in {openclaw, codex}
|
||||
status = "BLOCKED_EXECUTOR_HEALTH_UNKNOWN" if has_unknown else "BLOCKED_NO_HEALTHY_EXECUTOR"
|
||||
return QuorumDecision(False, "P1", status, None, None, True)
|
||||
|
||||
if len(healthy) == 1:
|
||||
active = healthy[0]
|
||||
standby = "codex" if active == "openclaw" else "openclaw"
|
||||
return QuorumDecision(True, "P3", "EXECUTOR_QUORUM_PASS", active, standby, False)
|
||||
|
||||
active = choose_executor(workload)
|
||||
standby = "codex" if active == "openclaw" else "openclaw"
|
||||
return QuorumDecision(True, "NONE", "EXECUTOR_QUORUM_PASS", active, standby, False)
|
||||
|
||||
|
||||
class QuorumStore:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
expected_uid = os.geteuid() if os.name == "posix" else None
|
||||
validate_secure_directory(path.parent, expected_uid=expected_uid)
|
||||
if path.exists() or path.is_symlink():
|
||||
info = path.lstat()
|
||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
||||
raise ControllerError(f"unsafe database path rejected: {path}")
|
||||
if expected_uid is not None and info.st_uid != expected_uid:
|
||||
raise ControllerError(f"unexpected database owner: {path}")
|
||||
self.db = sqlite3.connect(str(path), isolation_level=None, timeout=30)
|
||||
if os.name == "posix":
|
||||
path.chmod(0o600)
|
||||
self.db.row_factory = sqlite3.Row
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.execute("PRAGMA synchronous=FULL")
|
||||
self._initialize()
|
||||
|
||||
def close(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
payload_digest TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
work_unit TEXT PRIMARY KEY,
|
||||
contract_commit TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
workload TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
active_executor TEXT,
|
||||
standby_executor TEXT,
|
||||
checkpoint TEXT,
|
||||
lease_owner TEXT,
|
||||
lease_until REAL,
|
||||
interrupted_executor TEXT,
|
||||
recovery_priority TEXT NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lifecycle (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
work_unit TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
executor TEXT,
|
||||
state_version INTEGER NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS effects (
|
||||
effect_id TEXT PRIMARY KEY,
|
||||
work_unit TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
outbox_id TEXT PRIMARY KEY,
|
||||
destination TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt REAL NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS blockers (
|
||||
blocker_key TEXT PRIMARY KEY,
|
||||
occurrences INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observed_refs (
|
||||
ref_name TEXT PRIMARY KEY,
|
||||
oid TEXT NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS summaries (
|
||||
work_unit TEXT PRIMARY KEY,
|
||||
summary_digest TEXT NOT NULL,
|
||||
summary_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS lifecycle_once
|
||||
ON lifecycle(work_unit, event_type, COALESCE(executor, ''), state_version);
|
||||
"""
|
||||
)
|
||||
|
||||
def ingest_event(self, event_id: str, payload: Mapping[str, Any], source: str) -> bool:
|
||||
validate_metadata_only(payload)
|
||||
encoded = canonical_json(payload)
|
||||
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
existing = self.db.execute("SELECT payload_digest FROM events WHERE event_id=?", (event_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["payload_digest"] != digest:
|
||||
self.db.execute("UPDATE events SET status='DEAD_LETTER_CONFLICT' WHERE event_id=?", (event_id,))
|
||||
raise EventConflict(f"event id payload conflict: {event_id}")
|
||||
return False
|
||||
self.db.execute(
|
||||
"INSERT INTO events VALUES(?,?,?,?,?,?)",
|
||||
(event_id, digest, encoded, source, "ACCEPTED", time.time()),
|
||||
)
|
||||
return True
|
||||
|
||||
def upsert_task(
|
||||
self,
|
||||
*,
|
||||
work_unit: str,
|
||||
contract_commit: str,
|
||||
event_id: str,
|
||||
state_version: int,
|
||||
workload: str,
|
||||
decision: QuorumDecision,
|
||||
checkpoint: str | None = None,
|
||||
) -> str:
|
||||
now = time.time()
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
current = self.db.execute("SELECT * FROM tasks WHERE work_unit=?", (work_unit,)).fetchone()
|
||||
if current and state_version < current["state_version"]:
|
||||
raise StaleStateVersion(f"stale state version for {work_unit}")
|
||||
interrupted: str | None = None
|
||||
recovery_priority = decision.priority
|
||||
status = "BLOCKED"
|
||||
if decision.allowed:
|
||||
status = "READY"
|
||||
if current and current["active_executor"] and current["active_executor"] != decision.active_executor:
|
||||
interrupted = current["active_executor"]
|
||||
recovery_priority = "P2"
|
||||
status = "INTERRUPTED_AGENT_FAILOVER"
|
||||
if checkpoint is None:
|
||||
checkpoint = current["checkpoint"]
|
||||
if current:
|
||||
if contract_commit != current["contract_commit"] or event_id != current["event_id"]:
|
||||
raise ControllerError("failover must preserve contract_commit and event_id")
|
||||
if (
|
||||
current["state_version"] == state_version
|
||||
and current["active_executor"] == decision.active_executor
|
||||
and current["status"] in {"RUNNING", "COMPLETED"}
|
||||
):
|
||||
self.db.execute("COMMIT")
|
||||
return current["status"]
|
||||
self.db.execute(
|
||||
"""UPDATE tasks SET state_version=?, workload=?, status=?, active_executor=?,
|
||||
standby_executor=?, checkpoint=?, interrupted_executor=?, recovery_priority=?, updated_at=? WHERE work_unit=?""",
|
||||
(
|
||||
state_version,
|
||||
workload,
|
||||
status,
|
||||
decision.active_executor,
|
||||
decision.standby_executor,
|
||||
checkpoint,
|
||||
interrupted,
|
||||
recovery_priority,
|
||||
now,
|
||||
work_unit,
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.db.execute(
|
||||
"INSERT INTO tasks VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
work_unit,
|
||||
contract_commit,
|
||||
event_id,
|
||||
state_version,
|
||||
workload,
|
||||
status,
|
||||
decision.active_executor,
|
||||
decision.standby_executor,
|
||||
checkpoint,
|
||||
None,
|
||||
None,
|
||||
interrupted,
|
||||
recovery_priority,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self.db.execute("COMMIT")
|
||||
return status
|
||||
except Exception:
|
||||
self.db.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def claim_lease(self, work_unit: str, worker: str, ttl_seconds: int, now: float | None = None) -> bool:
|
||||
now = time.time() if now is None else now
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
task = self.db.execute(
|
||||
"SELECT active_executor, status, lease_owner, lease_until FROM tasks WHERE work_unit=?",
|
||||
(work_unit,),
|
||||
).fetchone()
|
||||
if not task:
|
||||
raise ControllerError(f"unknown work unit: {work_unit}")
|
||||
if worker != task["active_executor"]:
|
||||
self.db.execute("ROLLBACK")
|
||||
return False
|
||||
if task["status"] in {"BLOCKED", "COMPLETED"}:
|
||||
self.db.execute("ROLLBACK")
|
||||
return False
|
||||
lease_live = task["lease_owner"] and task["lease_until"] and task["lease_until"] > now
|
||||
if lease_live and task["lease_owner"] != worker:
|
||||
self.db.execute("ROLLBACK")
|
||||
return False
|
||||
self.db.execute(
|
||||
"UPDATE tasks SET lease_owner=?, lease_until=?, status='RUNNING', updated_at=? WHERE work_unit=?",
|
||||
(worker, now + ttl_seconds, now, work_unit),
|
||||
)
|
||||
self.db.execute("COMMIT")
|
||||
return True
|
||||
except Exception:
|
||||
if self.db.in_transaction:
|
||||
self.db.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def dispatch_task(
|
||||
self,
|
||||
*,
|
||||
work_unit: str,
|
||||
contract_commit: str,
|
||||
event_id: str,
|
||||
state_version: int,
|
||||
workload: str,
|
||||
decision: QuorumDecision,
|
||||
checkpoint: str | None = None,
|
||||
) -> str:
|
||||
status = self.upsert_task(
|
||||
work_unit=work_unit,
|
||||
contract_commit=contract_commit,
|
||||
event_id=event_id,
|
||||
state_version=state_version,
|
||||
workload=workload,
|
||||
decision=decision,
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
if decision.allowed and status != "COMPLETED":
|
||||
metadata = {
|
||||
"contract_commit": contract_commit,
|
||||
"event_id": event_id,
|
||||
"status": status,
|
||||
}
|
||||
self.append_lifecycle(work_unit, "TASK_DISPATCHED", decision.active_executor, state_version, metadata)
|
||||
self.enqueue(
|
||||
f"dispatch:{work_unit}:{state_version}:{decision.active_executor}",
|
||||
"gateway",
|
||||
{"event_type": "TASK_DISPATCHED", "work_unit": work_unit, **metadata},
|
||||
)
|
||||
return status
|
||||
|
||||
def start_task(self, work_unit: str, executor: str, state_version: int, lease_ttl_seconds: int) -> bool:
|
||||
if not self.claim_lease(work_unit, executor, lease_ttl_seconds):
|
||||
return False
|
||||
self.append_lifecycle(work_unit, "TASK_STARTED", executor, state_version, {"status": "RUNNING"})
|
||||
self.enqueue(
|
||||
f"started:{work_unit}:{state_version}:{executor}",
|
||||
"gateway",
|
||||
{"event_type": "TASK_STARTED", "work_unit": work_unit, "executor": executor, "state_version": state_version},
|
||||
)
|
||||
return True
|
||||
|
||||
def end_task(
|
||||
self,
|
||||
work_unit: str,
|
||||
executor: str,
|
||||
state_version: int,
|
||||
result: str,
|
||||
summary: Mapping[str, Any],
|
||||
*,
|
||||
project_completed: bool = False,
|
||||
) -> None:
|
||||
validate_metadata_only(summary)
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
task = self.db.execute("SELECT active_executor,state_version FROM tasks WHERE work_unit=?", (work_unit,)).fetchone()
|
||||
if not task or task["active_executor"] != executor:
|
||||
raise ControllerError("only the active executor may end a task")
|
||||
if task["state_version"] != state_version:
|
||||
raise StaleStateVersion(f"stale state version for {work_unit}")
|
||||
self.db.execute(
|
||||
"UPDATE tasks SET status='COMPLETED', lease_owner=NULL, lease_until=NULL, updated_at=? WHERE work_unit=?",
|
||||
(time.time(), work_unit),
|
||||
)
|
||||
self.append_lifecycle(work_unit, "TASK_ENDED", executor, state_version, {"result": result})
|
||||
self.append_lifecycle(work_unit, "DECISION_APPLIED", executor, state_version, {"result": result})
|
||||
self.append_lifecycle(work_unit, "PROJECT_SUMMARY", executor, state_version, summary)
|
||||
if project_completed:
|
||||
self.append_lifecycle(work_unit, "PROJECT_COMPLETED", executor, state_version, {"result": result})
|
||||
self.persist_summary(work_unit, summary)
|
||||
outbound = (
|
||||
("TASK_ENDED", {"result": result}),
|
||||
("DECISION_APPLIED", {"result": result}),
|
||||
("PROJECT_SUMMARY", dict(summary)),
|
||||
)
|
||||
for event_type, metadata in outbound:
|
||||
self.enqueue(
|
||||
f"{event_type.lower()}:{work_unit}:{state_version}:{executor}",
|
||||
"gateway",
|
||||
{
|
||||
"event_type": event_type,
|
||||
"work_unit": work_unit,
|
||||
"executor": executor,
|
||||
"state_version": state_version,
|
||||
**metadata,
|
||||
},
|
||||
)
|
||||
if project_completed:
|
||||
self.enqueue(
|
||||
f"project_completed:{work_unit}:{state_version}:{executor}",
|
||||
"gateway",
|
||||
{
|
||||
"event_type": "PROJECT_COMPLETED",
|
||||
"work_unit": work_unit,
|
||||
"executor": executor,
|
||||
"state_version": state_version,
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
self.db.execute("COMMIT")
|
||||
except Exception:
|
||||
if self.db.in_transaction:
|
||||
self.db.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def record_effect(self, effect_id: str, work_unit: str, payload: Mapping[str, Any]) -> bool:
|
||||
validate_metadata_only(payload)
|
||||
digest = digest_json(payload)
|
||||
existing = self.db.execute("SELECT payload_digest FROM effects WHERE effect_id=?", (effect_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["payload_digest"] != digest:
|
||||
raise EventConflict(f"effect id payload conflict: {effect_id}")
|
||||
return False
|
||||
self.db.execute(
|
||||
"INSERT INTO effects VALUES(?,?,?,?,?)",
|
||||
(effect_id, work_unit, digest, "COMPLETED", time.time()),
|
||||
)
|
||||
return True
|
||||
|
||||
def append_lifecycle(
|
||||
self,
|
||||
work_unit: str,
|
||||
event_type: str,
|
||||
executor: str | None,
|
||||
state_version: int,
|
||||
metadata: Mapping[str, Any],
|
||||
) -> None:
|
||||
if event_type not in ALLOWED_LIFECYCLE_EVENTS:
|
||||
raise ControllerError(f"unsupported lifecycle event: {event_type}")
|
||||
validate_metadata_only(metadata)
|
||||
self.db.execute(
|
||||
"INSERT OR IGNORE INTO lifecycle(work_unit,event_type,executor,state_version,metadata_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(work_unit, event_type, executor, state_version, canonical_json(metadata), time.time()),
|
||||
)
|
||||
|
||||
def persist_summary(self, work_unit: str, summary: Mapping[str, Any]) -> None:
|
||||
validate_metadata_only(summary)
|
||||
encoded = canonical_json(summary)
|
||||
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO summaries VALUES(?,?,?,?)",
|
||||
(work_unit, digest, encoded, time.time()),
|
||||
)
|
||||
|
||||
def enqueue(self, outbox_id: str, destination: str, payload: Mapping[str, Any]) -> bool:
|
||||
validate_metadata_only(payload)
|
||||
encoded = canonical_json(payload)
|
||||
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
existing = self.db.execute("SELECT payload_digest FROM outbox WHERE outbox_id=?", (outbox_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["payload_digest"] != digest:
|
||||
raise EventConflict(f"outbox id payload conflict: {outbox_id}")
|
||||
return False
|
||||
self.db.execute(
|
||||
"INSERT INTO outbox VALUES(?,?,?,?,?,?,?,?)",
|
||||
(outbox_id, destination, digest, encoded, "PENDING", 0, time.time(), time.time()),
|
||||
)
|
||||
return True
|
||||
|
||||
def record_blocker(self, blocker_key: str, threshold: int) -> str:
|
||||
now = time.time()
|
||||
row = self.db.execute("SELECT occurrences FROM blockers WHERE blocker_key=?", (blocker_key,)).fetchone()
|
||||
occurrences = (row["occurrences"] if row else 0) + 1
|
||||
status = "DEAD_LETTER" if occurrences > threshold else "RETRY"
|
||||
self.db.execute(
|
||||
"INSERT INTO blockers VALUES(?,?,?,?) ON CONFLICT(blocker_key) DO UPDATE SET occurrences=excluded.occurrences,status=excluded.status,updated_at=excluded.updated_at",
|
||||
(blocker_key, occurrences, status, now),
|
||||
)
|
||||
return status
|
||||
|
||||
def deliver_outbox(self, sender: Callable[[Mapping[str, Any]], None], max_attempts: int, now: float | None = None) -> tuple[int, int]:
|
||||
now = time.time() if now is None else now
|
||||
sent = 0
|
||||
failed = 0
|
||||
rows = self.db.execute(
|
||||
"SELECT * FROM outbox WHERE status='PENDING' AND next_attempt<=? ORDER BY created_at,outbox_id LIMIT 100",
|
||||
(now,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload_json"])
|
||||
validate_metadata_only(payload)
|
||||
try:
|
||||
sender(payload)
|
||||
except Exception:
|
||||
attempts = row["attempts"] + 1
|
||||
status = "DEAD_LETTER" if attempts >= max_attempts else "PENDING"
|
||||
delay = min(300, 2 ** min(attempts, 8))
|
||||
self.db.execute(
|
||||
"UPDATE outbox SET attempts=?,status=?,next_attempt=? WHERE outbox_id=?",
|
||||
(attempts, status, now + delay, row["outbox_id"]),
|
||||
)
|
||||
failed += 1
|
||||
else:
|
||||
self.db.execute(
|
||||
"UPDATE outbox SET attempts=attempts+1,status='SENT',next_attempt=? WHERE outbox_id=?",
|
||||
(now, row["outbox_id"]),
|
||||
)
|
||||
sent += 1
|
||||
return sent, failed
|
||||
|
||||
def observe_ref(self, ref_name: str, oid: str) -> bool:
|
||||
row = self.db.execute("SELECT oid FROM observed_refs WHERE ref_name=?", (ref_name,)).fetchone()
|
||||
changed = row is not None and row["oid"] != oid
|
||||
self.db.execute(
|
||||
"INSERT INTO observed_refs VALUES(?,?,?) ON CONFLICT(ref_name) DO UPDATE SET oid=excluded.oid,updated_at=excluded.updated_at",
|
||||
(ref_name, oid, time.time()),
|
||||
)
|
||||
return changed
|
||||
|
||||
|
||||
def spool_after_http_failure(
|
||||
sender: Callable[[Mapping[str, Any]], None],
|
||||
payload: Mapping[str, Any],
|
||||
spool_dir: Path,
|
||||
event_id: str,
|
||||
) -> bool:
|
||||
validate_metadata_only(payload)
|
||||
try:
|
||||
sender(payload)
|
||||
return True
|
||||
except Exception:
|
||||
atomic_json_write(spool_dir / f"{event_id}.json", {"event_id": event_id, "payload": dict(payload)})
|
||||
return False
|
||||
|
||||
|
||||
def poll_file_inbox(store: QuorumStore, inbox_dir: Path) -> int:
|
||||
if not inbox_dir.exists():
|
||||
return 0
|
||||
validate_secure_directory(inbox_dir, expected_uid=os.geteuid() if os.name == "posix" else None)
|
||||
count = 0
|
||||
for path in sorted(inbox_dir.glob("*.json")):
|
||||
if path.is_symlink():
|
||||
raise ControllerError(f"symlink inbox entry rejected: {path}")
|
||||
item = read_secure_json(path, expected_uid=os.geteuid() if os.name == "posix" else None)
|
||||
event_id = item.get("event_id")
|
||||
payload = item.get("payload")
|
||||
if not isinstance(event_id, str) or not isinstance(payload, dict):
|
||||
raise ControllerError(f"invalid inbox envelope: {path}")
|
||||
if store.ingest_event(event_id, payload, "file-inbox"):
|
||||
count += 1
|
||||
path.unlink()
|
||||
return count
|
||||
|
||||
|
||||
def resolve_git_ref(repo: Path, ref_name: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--verify", ref_name],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
oid = completed.stdout.strip()
|
||||
if len(oid) != 40 or any(character not in "0123456789abcdef" for character in oid.lower()):
|
||||
raise ControllerError("invalid Git object id")
|
||||
return oid
|
||||
|
||||
|
||||
def poll_git_refs(
|
||||
store: QuorumStore,
|
||||
repo: Path,
|
||||
refs: Iterable[str],
|
||||
resolver: Callable[[Path, str], str] = resolve_git_ref,
|
||||
) -> int:
|
||||
changed = 0
|
||||
for ref_name in refs:
|
||||
if not ref_name.startswith("refs/") or ".." in ref_name or ref_name.endswith("/"):
|
||||
raise ControllerError(f"invalid Git ref: {ref_name}")
|
||||
oid = resolver(repo, ref_name)
|
||||
if store.observe_ref(ref_name, oid):
|
||||
event_id = f"gitref:{ref_name}:{oid}"
|
||||
if store.ingest_event(event_id, {"ref": ref_name, "oid": oid}, "git-ref-poll"):
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def responses_fallback_allowed(
|
||||
workspace_agent_ok: bool,
|
||||
receipt: Mapping[str, Any],
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> bool:
|
||||
validate_metadata_only(receipt)
|
||||
if workspace_agent_ok:
|
||||
return False
|
||||
responses = receipt.get("responses_api")
|
||||
return (
|
||||
validate_health_receipt(receipt, "central_gpt", now=now) is Health.HEALTHY
|
||||
and isinstance(responses, Mapping)
|
||||
and responses.get("validated_live") is True
|
||||
)
|
||||
|
||||
|
||||
def post_json(url: str, payload: Mapping[str, Any], timeout: float = 5.0) -> None:
|
||||
validate_metadata_only(payload)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=canonical_json(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
if response.status < 200 or response.status >= 300:
|
||||
raise urllib.error.HTTPError(url, response.status, "unexpected status", response.headers, None)
|
||||
|
||||
|
||||
def run_once(config_path: Path) -> int:
|
||||
config = read_secure_json(config_path, expected_uid=0 if os.name == "posix" else None)
|
||||
store = QuorumStore(Path(config["database_path"]))
|
||||
try:
|
||||
receipts: dict[str, Health] = {}
|
||||
for subject in ("central_gpt", "openclaw", "codex"):
|
||||
receipt_path = Path(config["health_receipts"][subject])
|
||||
try:
|
||||
receipt = read_secure_json(receipt_path, expected_uid=0 if os.name == "posix" else None)
|
||||
receipts[subject] = validate_health_receipt(
|
||||
receipt,
|
||||
subject,
|
||||
max_age_seconds=int(config.get("max_health_age_seconds", 60)),
|
||||
)
|
||||
except (FileNotFoundError, ControllerError, json.JSONDecodeError):
|
||||
receipts[subject] = Health.UNKNOWN
|
||||
decision = evaluate_quorum(
|
||||
receipts["central_gpt"], receipts["openclaw"], receipts["codex"], config.get("default_workload", "large_repetitive_infrastructure_monitoring")
|
||||
)
|
||||
store.enqueue(
|
||||
f"quorum-{time.time_ns()}",
|
||||
"gateway",
|
||||
{
|
||||
"event_type": "DECISION_APPLIED",
|
||||
"allowed": decision.allowed,
|
||||
"priority": decision.priority,
|
||||
"status": decision.status,
|
||||
"active_executor": decision.active_executor,
|
||||
"standby_executor": decision.standby_executor,
|
||||
},
|
||||
)
|
||||
poll_file_inbox(store, Path(config["file_inbox_path"]))
|
||||
git_polling = config.get("git_ref_polling")
|
||||
if isinstance(git_polling, Mapping):
|
||||
poll_git_refs(store, Path(git_polling["repository"]), git_polling["refs"])
|
||||
store.deliver_outbox(
|
||||
lambda payload: post_json(config["gateway_event_url"], payload),
|
||||
max_attempts=int(config["blocker_retry_threshold"]),
|
||||
)
|
||||
return 0 if decision.allowed else 2
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
parser.add_argument("--once", action="store_true", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return run_once(args.config)
|
||||
except (ControllerError, KeyError, OSError, sqlite3.Error, json.JSONDecodeError) as error:
|
||||
print(f"automation-quorum-controller: fail-closed: {type(error).__name__}", file=os.sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../schemas/automation-quorum-controller.schema.json",
|
||||
"database_path": "/var/lib/aios-automation-quorum/controller.sqlite3",
|
||||
"file_inbox_path": "/var/lib/aios-automation-quorum/inbox",
|
||||
"default_workload": "large_repetitive_infrastructure_monitoring",
|
||||
"health_receipts": {
|
||||
"central_gpt": "/run/aios-health/central-gpt.json",
|
||||
"openclaw": "/run/aios-health/openclaw.json",
|
||||
"codex": "/run/aios-health/codex.json"
|
||||
},
|
||||
"git_ref_polling": {
|
||||
"repository": "/data/aios-gitops/repo.git",
|
||||
"refs": [
|
||||
"refs/heads/task/governance/v2r1-personal-weixin-production-contract-20260717"
|
||||
]
|
||||
},
|
||||
"blocker_retry_threshold": 3,
|
||||
"max_health_age_seconds": 60,
|
||||
"lease_ttl_seconds": 120,
|
||||
"gateway_event_url": "http://127.0.0.1:8765/v1/events"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# V1R4C6 Backup Result
|
||||
|
||||
- version: V1R4C6
|
||||
- status: NOT_STARTED
|
||||
- reason: No confirmed production Stack, persistent volume, database migration target, or V1R4C5 image digest is bound to this source release. A backup cannot be claimed or executed safely without an identified target datastore.
|
||||
- prohibited_actions_not_performed: production database deletion; persistent-volume deletion; docker system prune --volumes; direct copy of live database files.
|
||||
- required_before_resume: confirmed Stack identity and its documented backup/restore command.
|
||||
@@ -0,0 +1,14 @@
|
||||
# V1R4C6 Deployment Result
|
||||
|
||||
- version: V1R4C6
|
||||
- source_commit: e204a90398bf646d46245b3088226749fa056edd
|
||||
- tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
|
||||
- image_repository: UNRESOLVED
|
||||
- image_digest: UNRESOLVED
|
||||
- previous_image_digest: UNRESOLVED
|
||||
- portainer_environment: UNRESOLVED
|
||||
- portainer_stack: UNRESOLVED
|
||||
- deployment_action: NOT_EXECUTED
|
||||
- reason: No source-to-image-to-Portainer-Stack mapping exists. Existing Portainer containers and known Docker workloads do not expose a V1R4C6/e204 deployment target or Portainer stack labels. The active GitOps API is a direct systemd Python process, not a Portainer-managed Docker Stack.
|
||||
- rollback_status: NOT_APPLICABLE_NO_CHANGE
|
||||
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
|
||||
@@ -0,0 +1,15 @@
|
||||
# V1R4C6 Production Preflight Result
|
||||
|
||||
- version: V1R4C6
|
||||
- started_at_utc: 2026-07-11T16:57:04.0596039Z
|
||||
- source_tag_object: 356a8a8c1860b94b7d61a456c29228c88e57d963
|
||||
- source_tag_commit: d8cf6058e99f5c7f2dfd2b45a406c0a7edf955b2
|
||||
- source_commit: e204a90398bf646d46245b3088226749fa056edd
|
||||
- tag_to_source_relation: tag commit is direct parent of source commit (verified)
|
||||
- production_host_identifier: cn-network-mkt (hw / 1.95.164.183)
|
||||
- host_preflight: Docker 29.5.3; root filesystem 29 percent used; memory available approximately 56 GiB
|
||||
- GitOps_runtime: active at /data/aios-gitops/workspace via aios-gitops-model-api.service
|
||||
- runtime_base: current_baseline V1R4C6; previous_baseline V1R4C5; git e204a90398bf646d46245b3088226749fa056edd; service status ok
|
||||
- Portainer discovery: Portainer instances are reachable on ubuntu-admin and ubuntu-aihost, but no confirmed Portainer Environment/Stack maps to V1R4C6 or e204a90398bf646d46245b3088226749fa056edd.
|
||||
- source_build_contract: target source contains only deploy/gitea/docker-compose.yml and no application Dockerfile/Containerfile or V1R4C6 image repository declaration.
|
||||
- status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
|
||||
@@ -0,0 +1,7 @@
|
||||
# V1R4C6 Rollback Result
|
||||
|
||||
- version: V1R4C6
|
||||
- rollback_required: No
|
||||
- rollback_executed: No
|
||||
- reason: No container, Stack, database, image, or production configuration was changed during this run.
|
||||
- captured_v1r4c5_digest: UNRESOLVED because no matching current production Stack was confirmed.
|
||||
@@ -0,0 +1,11 @@
|
||||
# V1R4C6 Verification Result
|
||||
|
||||
- version: V1R4C6
|
||||
- healthcheck_result: GitOps adapter /health = 200 and /api/base = 200; this verifies the control-plane API only, not a V1R4C6 production deployment.
|
||||
- gate_a_result: NOT_RUN (production admin workbench target and authenticated test path unresolved)
|
||||
- gate_b_result: NOT_RUN (production Search Gateway target/provider binding unresolved)
|
||||
- gate_c_result: NOT_RUN (production Agent Platform target/runtime request path unresolved)
|
||||
- runtime_acceptance_result: NOT_RUN (0/8; cannot reuse non-production results)
|
||||
- critical_control_plane_finding: gitea-branch-consistency-sentinel is failed with five Git-ref-to-Gitea-metadata drift entries and autonomous safe mode emitted. This is a hard safety gate failure.
|
||||
- no_unresolved_critical_errors: false
|
||||
- final_status: BLOCKED_MISSING_CONFIRMED_PRODUCTION_STACK
|
||||
@@ -0,0 +1,101 @@
|
||||
# AIOS GPT + one-executor automation quorum
|
||||
|
||||
## Authority and release boundary
|
||||
|
||||
The Gateway is the only authoritative state writer. It must hold the SQLite writer lease before changing task state. A model response, service process, configured credential, or agent presence does not establish health. Release remains paused until all live acceptance rows are backed by logs and exit codes.
|
||||
|
||||
Minimum quorum:
|
||||
|
||||
```text
|
||||
central_gpt=HEALTHY AND (openclaw=HEALTHY OR codex=HEALTHY)
|
||||
```
|
||||
|
||||
`UNKNOWN` never counts as healthy. If one executor is proven healthy, an unhealthy or unknown standby is P3 telemetry only and cannot block the active gate. If no executor is proven healthy, the controller blocks. Central GPT `UNKNOWN` or `UNHEALTHY` always blocks.
|
||||
|
||||
## Live health receipts
|
||||
|
||||
The controller reads three root-owned metadata-only JSON receipts from `/run/aios-health`. Files must be regular files, must not be symlinks, and neither files nor parent directories may be group/world writable. The controller rejects fields named `secret`, `token`, `password`, `api_key`, `authorization`, `credential`, `private_key`, `environment`, `env`, `argv`, `stdout`, or `stderr` at any depth.
|
||||
|
||||
A receipt claiming `HEALTHY` must contain:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "codex",
|
||||
"health": "HEALTHY",
|
||||
"live_probe_succeeded": true,
|
||||
"observed_at_epoch": 1784347200
|
||||
}
|
||||
```
|
||||
|
||||
The central GPT receipt additionally requires either `workspace_agent.validated_live=true` or `responses_api.validated_live=true`. Responses fallback is allowed only after a real Responses decision probe succeeded; credential/configuration presence is insufficient.
|
||||
|
||||
## Durable behavior
|
||||
|
||||
SQLite persists ingress event digests, optimistic task state versions, leases, checkpoints, lifecycle events, effect IDs, outbox messages, blocker counts, observed Git refs, and project summaries. Reusing an event/effect/outbox ID with different content fails closed. An expired lease may be reclaimed; a live lease cannot be stolen.
|
||||
|
||||
On active-executor failure, a healthy standby receives the same `contract_commit`, `event_id`, state version, and checkpoint. The former executor is recorded as `INTERRUPTED_AGENT_FAILOVER`. Unique effect IDs prevent replay of completed side effects.
|
||||
|
||||
The Gateway persists these events even when OpenClaw is offline:
|
||||
|
||||
```text
|
||||
TASK_DISPATCHED
|
||||
TASK_STARTED
|
||||
TASK_ENDED
|
||||
DECISION_APPLIED
|
||||
PROJECT_SUMMARY
|
||||
PROJECT_COMPLETED
|
||||
```
|
||||
|
||||
OpenClaw mirroring is optional and uses the durable outbox with bounded retry and dead-letter. HTTP ingress failure spools a metadata-only file for polling; the configured local Git refs are also polled and changes missed by webhooks become idempotent events. Repeated blockers beyond the configured threshold enter `DEAD_LETTER`.
|
||||
|
||||
## Install, verify, and rollback
|
||||
|
||||
Installation is versioned and does not overwrite an existing release:
|
||||
|
||||
```bash
|
||||
sudo scripts/install_automation_quorum_controller.sh "$PWD" 20260718.1
|
||||
sudo systemctl enable --now aios-automation-quorum-controller.timer
|
||||
```
|
||||
|
||||
Run deterministic verification before enablement:
|
||||
|
||||
```bash
|
||||
scripts/verify_automation_quorum_controller.sh "$PWD"
|
||||
```
|
||||
|
||||
Rollback accepts only an existing non-symlink directory below `/opt/aios-automation-quorum/releases`. Repeating rollback to the current target is a no-op:
|
||||
|
||||
```bash
|
||||
sudo scripts/rollback_automation_quorum_controller.sh \
|
||||
/opt/aios-automation-quorum/releases/20260718.0
|
||||
```
|
||||
|
||||
## Live acceptance matrix
|
||||
|
||||
Each row requires captured logs, timestamps, exit codes, committed OIDs, Gitleaks output, and where applicable live Gateway DB evidence. Static unit tests are necessary but cannot close the gate.
|
||||
|
||||
1. GPT + OpenClaw healthy, Codex stopped: two unattended cycles.
|
||||
2. GPT + Codex healthy, OpenClaw stopped: two unattended cycles.
|
||||
3. Both executors healthy: OpenClaw gets repetitive infrastructure; Codex gets complex code/security.
|
||||
4. Active stops mid-task: standby continues from checkpoint with no duplicate effect.
|
||||
5. Standby alone stops: active flow continues and no repair gate is created.
|
||||
6. GPT stops: execution blocks even with two healthy executors.
|
||||
7. GPT healthy and both executors stop: `BLOCKED_NO_HEALTHY_EXECUTOR`.
|
||||
8. `UNKNOWN` is never counted as healthy.
|
||||
9. HTTP ingress failure recovers through file polling.
|
||||
10. Missed webhook recovers through Git ref polling.
|
||||
11. Gateway restart restores events, leases, and outbox.
|
||||
12. Repeated event ID does not create a second dispatch; changed payload dead-letters.
|
||||
13. Repeated blocker beyond threshold dead-letters.
|
||||
14. Workspace Agent failure falls back only with a live-validated Responses channel.
|
||||
15. OpenClaw offline: Codex lifecycle and project summary remain complete in Gateway.
|
||||
16. Codex offline: OpenClaw lifecycle and project summary remain complete in Gateway.
|
||||
|
||||
Until all rows pass against the live service, retain:
|
||||
|
||||
```text
|
||||
AUTOMATION_CLOSED=NOT_STARTED
|
||||
BASELINE=V1R4C6
|
||||
RELEASE=paused
|
||||
PRODUCTION_RELEASE=NOT_STARTED
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
# 微信文本-图片缓存隔离事故报告
|
||||
|
||||
## 结论
|
||||
|
||||
本次事故已经按最新备份链路回滚并完成第二轮评审修复。`3d9782fc346d1e7defde090ab8637e62ab28ab8a` 进一步修复了英文诊断文本和未确认引用文本误入图片路径的问题,`706c0b0` 则是后续最小修复提交。当前文档只记录证据、部署和验收边界,不把“真实 owner inbound 已完成”写成结论。
|
||||
|
||||
## 现象
|
||||
|
||||
- 纯文本消息被误答成旧图片回复,属于 stale-image reply on pure text。
|
||||
- 现场证据显示 `types=1` 且 `hasMedia=false`,但后续又进入 `message_type=image`、`image_count=0`、`implicit_image_message`,最终落到 `cached_image_expired`。
|
||||
- 这说明“诊断文本”被误判成图片意图后,又继承了 sender 级旧图缓存。
|
||||
|
||||
## 根因
|
||||
|
||||
- `fixes/patch_openclaw_weixin_hermes.py:768-839` 把文本意图判断和缓存回退放在同一条链上。
|
||||
- `looksLikeHermesImageFollowup()` 会把含图片关键字的诊断文本推进 image intent。
|
||||
- `resolveHermesFollowupImageContext()` 随后会读取 sender 级缓存;一旦缓存命中失败或过期,就会落到 `cached_image_expired`,从而让纯文本沾上旧图上下文。
|
||||
- `handleHermesImplicitImage()` 继续把消息送入 `handleHermes(..., "implicit_image_message")`,所以误判会沿着同一条图片处理通道传播。
|
||||
|
||||
## 护栏不变量
|
||||
|
||||
- 图片处理上下文必须来自当前消息图片、已确认的引用图片,或精确匹配的缓存图片 ID;缺少这三类证据时,纯文本不能进入 `handleHermesImplicitImage()`。
|
||||
- 未确认的引用文本不能继承 sender 级旧图缓存,必须返回 `referenced_image_not_confirmed`。
|
||||
- 只有精确缓存图片 ID 命中且缓存仍有效时,才允许走 `resolveHermesCachedImageEntry()` 的 `cached_image_matched` 路径。
|
||||
- `/hermes` 这类 slash 路径保持原有分流,slash/channel 面不变。
|
||||
- 这次修复的守卫目标,是把中英文诊断文本、未确认引用文本与图片缓存回退拆开,避免再把普通文本提升成图片意图。
|
||||
|
||||
## TDD 与幂等性
|
||||
|
||||
- `66d637fc0364a617c7b9fd8cd9baa68fb0368675`(`fix(weixin): isolate text from stale image cache`)引入了隔离修复和 `tests/test_weixin_image_followup_intent.py`。
|
||||
- `2de29c59bb946cc3db3e14e8a06270391ede8401`(`fix(weixin): add process-only runtime patch mode`)继续收紧 process-only 运行模式,保持回放幂等。
|
||||
- `3d9782fc346d1e7defde090ab8637e62ab28ab8a`(`fix(weixin): require confirmed image reference context`)补齐英文诊断文本和未确认引用文本用例,并把图片引用收紧到已确认上下文。
|
||||
- 用户实测的 TDD 路径为 RED `8 PASS / 1 FAIL`,随后 GREEN `9 PASS`。
|
||||
- 最小修复提交为 `706c0b0`,对应 quoted pure text 的最后一层收敛修复。
|
||||
- 第二轮回归结果为 `8 PASS`;node check 同样通过,重复应用补丁保持幂等。
|
||||
- 生产 npm 分发侧本身缺少 `tsconfig/tests`,baseline 与 patched 共享这一限制,所以这里不把分发目录的缺口当成回归点;验收以 node check 和 8 tests 为准。
|
||||
|
||||
## 用户实测补充
|
||||
|
||||
- 普通文本两项为 PASS。
|
||||
- quoted pure text 为 FAIL,日志证据包含 `ref_msg.message_item.type=0/NONE`、quoted id、`implicit_image_message`、`referenced_image_unresolved`。
|
||||
- 这组证据说明 quoted pure text 没有被当成真正的图片 follow-up,且没有越过引用确认边界。
|
||||
|
||||
## 回滚与备份
|
||||
|
||||
- 回滚按最新备份执行,基线指向 `/data/aios-gitops/runtime/latest-backup-dir.txt` 所示的最新备份目录。
|
||||
- 备份路径链路按供给证据记录为 `T064506Z`、`T074212Z`、`T081658Z`。
|
||||
- 第一轮恢复后的部署过程哈希记录为 `e9625d12` 和 `c00c58f8`。
|
||||
- v2 部署备份为 `/var/backups/aios/weixin-text-cache-isolation-20260711T094908Z`。
|
||||
- v2 部署文件哈希为 TS `024c6498284d1871b048f6157d5d3dbd4d34e54b53a05afd744b00458ceeb79f`、JS `d73b15d6752056192a1d1939b04107334419785b41175b39f1cbc1132ac65c47`;slash/channel 口径保持不变。
|
||||
- 首次部署因为 `-- No entries --` 门禁误判而回滚,随后纠正部署在线,PID 为 `2496603`。
|
||||
- 在线修复后的 hashes 为 `86dfe...` / `a9b854...`,`channel 4` accounts healthy,cache unchanged。
|
||||
|
||||
## 主线合并与生产发布
|
||||
|
||||
- 主线 fast-forward source rev 为 `9269e819972c2e709e0c27bed7eab590da6961c9`,本次不 tag、不 baseline。
|
||||
- 生产发布结论为 PASS,release dir 为 `/var/backups/aios/weixin-mainline-release-20260711T181842Z`。
|
||||
- old PID 为 `2655072`,new PID 为 `2656106`。
|
||||
- TS hash 为 `86dfe4543c4414bc86778a4b218c244a742a139c66e3c9f19cf6f0de871234c3`,JS hash 为 `a9b854015108020458ce6f31b9d9187eff82e170c4c214259b0afb9be6a7336f`。
|
||||
- `18789` 监听,`channel 4` accounts healthy,`error_count=0`,cache unchanged。
|
||||
- 一次参数门禁失败已回滚,且不影响最终发布。
|
||||
- owner quoted-text live acceptance 仍为 PENDING。
|
||||
|
||||
## 运行态验证
|
||||
|
||||
- 第一轮 gateway PID 由 `4025932` 变为 `1723576`;v2 部署后由 `1723576` 变为 `1876713`。
|
||||
- v2 现场/供给证据为 `active`、监听 `18789`、`error_count=0`、`channel gate pass`、`cache preserved`。
|
||||
- 最终 read-only gate 通过,说明部署后运行态未出现新的插件错误,channel 状态和缓存均保持预期。
|
||||
- quoted pure text 的第二次 owner live acceptance 仍为 PENDING。
|
||||
|
||||
## 未完成的真实验收
|
||||
|
||||
- owner 纯文本
|
||||
- diagnostic text
|
||||
- genuine image-followup message
|
||||
- quoted pure text 的第二次 owner live acceptance
|
||||
|
||||
以上四类消息仍需要由 owner 真实 inbound 到达,才能把这次“文本隔离”结论升格为完整的线上接受;本报告明确保留该项为 pending,不把它们写成已经完成。
|
||||
|
||||
## 引用文字语义修复与发布(2026-07-12)
|
||||
|
||||
- 后续 owner 实测证明图片误路由已消失,但模型在没有被引正文时根据 `chat_id/message_id/timestamp` 编造解释,因此图片隔离 PASS、语义验收 FAIL。
|
||||
- 现场 payload 的 `ref_msg.message_item.type=0/NONE` 只有 quoted ID 与时间字段,没有 `text_item`;session transcript 又没有原始 Weixin 数字 message ID,不能精确回表。
|
||||
- 出站 `clientId` 与引用的 19 位数字 `msg_id` 格式不同,发送 API 也不返回服务端 message ID。本次明确禁止 recent-message、时间窗口、session 顺序和近似 ID 猜测。
|
||||
- `1136b4186d7578cd77f255297204b10152c47dab` 增加证据优先处理:payload 明确含 `text_item.text` 时才构造引用上下文;引用存在但正文缺失时,经标准 dispatcher 确定性回复“无法读取被引用文字,请复制原文后再提问。”,并在模型 dispatch 前返回。
|
||||
- TDD 先出现两项预期 RED(quoted-text resolver 不存在),最终 GREEN 为 `17 passed`;`py_compile`、`git diff --check`、`node --check` 均通过。
|
||||
- Scratch 双跑验证只改变 `src/messaging/process-message.ts` 与 `dist/src/messaging/process-message.js`,第二次应用 diff 为 0;slash/channel 文件保持不变。
|
||||
- 主线为 `1136b4186d7578cd77f255297204b10152c47dab`,生产发布目录为 `/var/backups/aios/weixin-quoted-text-failclosed-release-20260712T054451Z`。
|
||||
- 发布后 PID 从 `2656106` 变为 `3670157`;TS hash 为 `1a73e735e9c8fb40a1f82d2a66bc022396c8105ed989aac95e11b66326e9fdf1`,JS hash 为 `1c76bf6ec3b45021e68b932cbf52222ba2130a867b95e41d7a91f261b991a304`。
|
||||
- `18789`、4 个 Weixin accounts、post-start error 0 和图片 cache unchanged 均通过。新的 owner live acceptance 仍为 PENDING。
|
||||
|
||||
## Owner 引用文字 Fail-Closed 验收(2026-07-12)
|
||||
|
||||
- Owner 引用一条微信未提供正文的文字消息并发送“是什么意思”,收到固定回复“无法读取被引用文字,请复制原文后再提问。”。
|
||||
- 日志窗口为 `2026-07-12 14:06:23.424-14:06:23.893 +08:00`:入站 `types=1`、`bodyLen=6`、`hasMedia=false`,出站 `textLen=20` 且发送成功。
|
||||
- 同一窗口 `model-fetch=0`、`implicit_image_message=0`、Hermes route=0,`quoted_text_unavailable=1` 且 `quoted_message_id=present`。
|
||||
- 该证据确认无正文引用不会进入模型猜测或图片缓存路径。Owner quoted-text no-body live acceptance 为 **PASS**。
|
||||
- 微信 payload 明确携带 `text_item.text` 时的 grounded explanation 和 genuine image follow-up 仍作为独立兼容性验收项保留。
|
||||
|
||||
## 可见引用摘要解析修复(2026-07-12)
|
||||
|
||||
- Owner 进一步确认微信客户端能够显示被引用正文,因此上一节的“未提供正文”只适用于旧 resolver 的观察结果,不代表协议 payload 没有可用文本。
|
||||
- 根因是插件 `inbound.ts` 已将协议字段 `ref_msg.title` 定义为引用摘要并拼入 `ctx.Body`,而 `extractHermesReferencedText()` 只读取 `ref_msg.message_item.text_item.text`,导致客户端可见摘要被错误丢弃。
|
||||
- `13c2a182860131abcad0f5a70ae8263477578588` 在真实 `text_item.text` 缺失时,仅从 reference envelope 读取 `ref_msg.title`;不会读取 `message_item.title` 或任意嵌套通用 `title`,也不改变图片、slash 或 channel 路由。
|
||||
- TDD 先得到 `18 passed / 1 failed`,失败项为可见 `ref_msg.title` 仍落到 `quoted_text_unavailable`;修复后为 `19 passed`,`py_compile`、`node --check` 与 `git diff --check` 通过。
|
||||
- 生产发布目录为 `/var/backups/aios/weixin-quoted-text-title-resolution-20260712T155112+0800`,PID 从 `3859376` 变为 `3860863`,post-start error 为 0,图片 cache unchanged。
|
||||
- 部署后 TS hash 为 `8e77ce61214688a2507b07f0cf1ee85401b58198b8271bebd69efc2681872726`,JS hash 为 `663db8d30a5cd5eb80fe46cf941cce6e6f5d04cb7e4b8d9b4d0086d87faba290`。
|
||||
- 可见正文引用的 owner live acceptance 尚待下一条真实微信消息确认;预期日志必须命中 `quoted_text_resolved`,且同一窗口不得出现 `quoted_text_unavailable` 或图片路由。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 本次仅更新本报告,没有修改代码、runtime、services 或 secrets。
|
||||
- 报告里没有任何凭据、密钥或原始敏感 ID。
|
||||
@@ -0,0 +1,67 @@
|
||||
# Weixin Quoted Text Fail-Closed Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Resolve explicit quoted text safely and fail closed without a model call when Weixin omits the quoted body.
|
||||
|
||||
**Architecture:** Add pure JS/TS reference-text helpers and one process hook guard to the existing patcher. Keep the patch surface limited to generated `process-message.ts/js`; do not introduce a persistent cache or modify send/monitor/API modules.
|
||||
|
||||
**Tech Stack:** Python patch generator, TypeScript/JavaScript, Node test harness, pytest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Sub-agents use `gpt-5.3-codex-spark`.
|
||||
- No force push, tag, baseline promotion, credential persistence, recent-message fallback, or approximate text matching.
|
||||
- Existing confirmed image and ordinary text behavior must remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Quoted Text Resolver With TDD
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_weixin_image_followup_intent.py`
|
||||
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `extractHermesReferencedText(items)` and `resolveHermesQuotedTextContext(items, currentText)` in TS and JS helper templates.
|
||||
|
||||
- [ ] Add a Node-backed failing test with `ref_msg.message_item.type=1` and explicit `text_item.text="被引用的正文"`; assert `reason="quoted_text_resolved"`, `handled=false`, and a prompt containing only delimited quoted/current text.
|
||||
- [ ] Add a Node-backed failing test with `type=0`, quoted ID, no text, and no confirmed image; assert `reason="quoted_text_unavailable"`, `handled=true`, and exact deterministic `replyText`.
|
||||
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py -k 'quoted_text'` and record expected RED because the resolver is absent.
|
||||
- [ ] Implement the smallest recursive explicit-text extractor. Accept only known referenced text fields; cap each extracted string at 4000 characters and reject blank values.
|
||||
- [ ] Implement the resolver in both TS and JS templates. Do not read session history, image latest cache, timestamps, or metadata values.
|
||||
- [ ] Re-run the focused tests and record GREEN.
|
||||
- [ ] Commit with `fix(weixin): fail closed on unavailable quoted text`.
|
||||
|
||||
### Task 2: Guard The Real Process Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_weixin_image_followup_intent.py`
|
||||
- Modify: `fixes/patch_openclaw_weixin_hermes.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `resolveHermesQuotedTextContext(items, currentText)`.
|
||||
- Produces: process hook behavior that replies deterministically or replaces only the ordinary text prompt.
|
||||
|
||||
- [ ] Add a failing JS hook test that stubs the ordinary model dispatch and reply function. For type 0 without text, assert reply called once and model called zero times.
|
||||
- [ ] Add a failing JS hook test for explicit quoted text. Assert model called once with delimited context and no message metadata.
|
||||
- [ ] Add regression assertions for plain text, current image, and exact quoted image.
|
||||
- [ ] Run focused tests and record RED for the missing guard.
|
||||
- [ ] Insert the resolver after reference/image evidence extraction and before ordinary model dispatch. Confirmed image references must bypass the text fail-closed branch.
|
||||
- [ ] Emit structured reason-only diagnostics without message bodies.
|
||||
- [ ] Run focused tests and record GREEN.
|
||||
- [ ] Commit with `fix(weixin): guard quoted text model dispatch`.
|
||||
|
||||
### Task 3: Idempotence, Review, And Release
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/incidents/WEIXIN_TEXT_IMAGE_CACHE_ISOLATION_20260711.md`
|
||||
|
||||
- [ ] Run `pytest -q tests/test_weixin_image_followup_intent.py` and require all tests pass.
|
||||
- [ ] Run `python3 -m py_compile fixes/patch_openclaw_weixin_hermes.py tests/test_weixin_image_followup_intent.py`.
|
||||
- [ ] Run `git diff --check`.
|
||||
- [ ] Apply `--process-only` twice to a scratch production-plugin copy and require identical second-run hashes; require slash/channel files unchanged.
|
||||
- [ ] Obtain independent spec and code review.
|
||||
- [ ] Update the incident document with RED/GREEN evidence, main revision, production hashes, rollback directory, and owner live acceptance state.
|
||||
- [ ] Merge by non-force fast-forward from a clean main-based branch, deploy only the two generated process-message files, restart only `openclaw-gateway.service`, and verify service, port 18789, four accounts, zero post-start errors, and unchanged image cache.
|
||||
- [ ] Owner live acceptance: quote a type 0 text message and verify deterministic no-body response; quote a payload that includes explicit text and verify grounded explanation.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Weixin Quoted Text Fail-Closed Design
|
||||
|
||||
## Goal
|
||||
|
||||
Prevent quoted-text questions from being answered with invented message metadata when Weixin does not provide the quoted body. Preserve normal text and confirmed image behavior.
|
||||
|
||||
## Evidence
|
||||
|
||||
- A quoted text event can arrive as `ref_msg.message_item.type=0` with only `msg_id` and timestamps.
|
||||
- Session transcripts retain text but not the original Weixin numeric message ID.
|
||||
- Outbound `clientId` values are not the same format as quoted numeric `msg_id` values, and the send API does not expose a server message ID.
|
||||
- Therefore recent-message, timestamp, session, and outbound-client-ID matching are not exact evidence and must not be used.
|
||||
|
||||
## Decision
|
||||
|
||||
Use an evidence-only resolver in `process-message`:
|
||||
|
||||
1. If reference payload contains an explicit text item, extract that text and build a bounded quoted-text context for the ordinary text model path.
|
||||
2. If reference context exists but has no explicit text and is not a confirmed image, return a deterministic response: `无法读取被引用文字,请复制原文后再提问。`
|
||||
3. The unresolved branch is handled before ordinary model dispatch and before Hermes image dispatch.
|
||||
4. Do not infer quoted text from metadata, recent messages, session order, timestamps, or approximate IDs.
|
||||
|
||||
No persistent text cache is introduced in this change. Outbound ID aliasing remains a separate observation task because an exact mapping has not been proved.
|
||||
|
||||
## Interfaces
|
||||
|
||||
- `extractHermesReferencedText(items): string` returns only explicit referenced text, otherwise an empty string.
|
||||
- `resolveHermesQuotedTextContext(items, currentText)` returns one of:
|
||||
- `{ referenced: false, handled: false, prompt: currentText, reason: "none" }`
|
||||
- `{ referenced: true, handled: false, prompt, reason: "quoted_text_resolved" }`
|
||||
- `{ referenced: true, handled: true, replyText: "无法读取被引用文字,请复制原文后再提问。", reason: "quoted_text_unavailable" }`
|
||||
- Confirmed referenced images bypass this text resolver and keep the existing exact image path.
|
||||
|
||||
## Privacy And Logging
|
||||
|
||||
- Do not log quoted or current text.
|
||||
- Log only `reason`, account ID, a one-way bounded hash of quoted ID when available, and boolean evidence flags.
|
||||
- Do not persist tokens, credentials, full payloads, or message text.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Explicit quoted text is supplied to the ordinary text model with a clear delimiter.
|
||||
- Type 0 reference without text returns the deterministic Chinese response and does not call the model.
|
||||
- Plain text, current image, and exact quoted image behavior remain unchanged.
|
||||
- Generated TS and JS remain byte-idempotent under `--process-only`.
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
|
||||
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
|
||||
function parseWeixinGetUpdatesJson(rawText: string): GetUpdatesResp {
|
||||
return JSON.parse(rawText, (key: string, value: unknown, context?: { source?: string }) => {
|
||||
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string") return value;
|
||||
if (typeof value !== "number") throw new Error(`invalid ${key} type in getUpdates response`);
|
||||
const source = context?.source;
|
||||
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source)) throw new Error(`unsafe numeric ${key} in getUpdates response`);
|
||||
return source;
|
||||
}) as GetUpdatesResp;
|
||||
}
|
||||
// AIOS Weixin lossless getUpdates JSON end'''
|
||||
|
||||
JS_HELPER = '''// AIOS Weixin lossless getUpdates JSON begin
|
||||
const WEIXIN_EXACT_ID_FIELDS = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
|
||||
function parseWeixinGetUpdatesJson(rawText) {
|
||||
return JSON.parse(rawText, (key, value, context) => {
|
||||
if (!WEIXIN_EXACT_ID_FIELDS.has(key) || value == null || typeof value === "string")
|
||||
return value;
|
||||
if (typeof value !== "number")
|
||||
throw new Error(`invalid ${key} type in getUpdates response`);
|
||||
const source = context?.source;
|
||||
if (!source || !/^(?:0|[1-9]\\d*)$/.test(source))
|
||||
throw new Error(`unsafe numeric ${key} in getUpdates response`);
|
||||
return source;
|
||||
});
|
||||
}
|
||||
// AIOS Weixin lossless getUpdates JSON end'''
|
||||
|
||||
|
||||
def insert_once(text: str, marker: str, addition: str) -> str:
|
||||
if addition in text:
|
||||
return text
|
||||
if marker not in text:
|
||||
raise RuntimeError(f'marker not found: {marker}')
|
||||
return text.replace(marker, marker + '\n' + addition, 1)
|
||||
|
||||
|
||||
def patch_api_ts(text: str) -> str:
|
||||
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + TS_HELPER)
|
||||
old = 'const resp: GetUpdatesResp = JSON.parse(rawText);'
|
||||
new = 'const resp: GetUpdatesResp = parseWeixinGetUpdatesJson(rawText);'
|
||||
if new not in text:
|
||||
if old not in text:
|
||||
raise RuntimeError('TypeScript getUpdates parser marker not found')
|
||||
text = text.replace(old, new, 1)
|
||||
return text
|
||||
|
||||
|
||||
def patch_api_js(text: str) -> str:
|
||||
text = insert_once(text, 'import { redactBody, redactUrl } from "../util/redact.js";', '\n' + JS_HELPER)
|
||||
old = 'const resp = JSON.parse(rawText);'
|
||||
new = 'const resp = parseWeixinGetUpdatesJson(rawText);'
|
||||
if new not in text:
|
||||
if old not in text:
|
||||
raise RuntimeError('JavaScript getUpdates parser marker not found')
|
||||
text = text.replace(old, new, 1)
|
||||
return text
|
||||
|
||||
|
||||
def patch_types_ts(text: str) -> str:
|
||||
new = 'message_id?: string | number;'
|
||||
if new in text:
|
||||
return text
|
||||
old = 'message_id?: number;'
|
||||
if old not in text:
|
||||
raise RuntimeError('message_id type marker not found')
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def write_if_changed(path: Path, content: str) -> bool:
|
||||
original = path.read_text(encoding='utf-8')
|
||||
if original == content:
|
||||
return False
|
||||
path.write_text(content, encoding='utf-8')
|
||||
return True
|
||||
|
||||
|
||||
def apply(plugin_root: Path) -> dict[str, bool]:
|
||||
paths = {
|
||||
'src/api/api.ts': patch_api_ts,
|
||||
'dist/src/api/api.js': patch_api_js,
|
||||
'src/api/types.ts': patch_types_ts,
|
||||
}
|
||||
changed: dict[str, bool] = {}
|
||||
for relative, transform in paths.items():
|
||||
path = plugin_root / relative
|
||||
if not path.exists():
|
||||
raise RuntimeError(f'missing plugin file: {path}')
|
||||
original = path.read_text(encoding='utf-8')
|
||||
changed[relative] = write_if_changed(path, transform(original))
|
||||
return changed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('plugin_root', type=Path)
|
||||
args = parser.parse_args()
|
||||
changed = apply(args.plugin_root.resolve())
|
||||
print(json.dumps({'plugin_root': str(args.plugin_root.resolve()), 'changed': changed}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Executable
+2726
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def replace_required(text: str, old: str, new: str, label: str) -> str:
|
||||
if new in text:
|
||||
return text
|
||||
if old not in text:
|
||||
raise RuntimeError(f'{label} marker not found')
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def patch_send_ts(text: str) -> str:
|
||||
text = replace_required(
|
||||
text,
|
||||
'}): Promise<{ messageId: string }> {\n const { to, text, opts } = params;\n',
|
||||
'}): Promise<{ messageId: string; createTimeMs: number }> {\n const { to, text, opts } = params;\n',
|
||||
'TS send return type',
|
||||
)
|
||||
text = replace_required(
|
||||
text,
|
||||
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
|
||||
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
|
||||
'TS create time',
|
||||
)
|
||||
text = replace_required(
|
||||
text,
|
||||
' });\n try {\n await sendMessageApi({',
|
||||
' });\n if (!req.msg?.item_list?.[0]) throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
|
||||
'TS request timestamp',
|
||||
)
|
||||
text = replace_required(
|
||||
text,
|
||||
' return { messageId: clientId };\n}\n\n/** Send a single structured MessageItem downstream. */',
|
||||
' return { messageId: clientId, createTimeMs };\n}\n\n/** Send a single structured MessageItem downstream. */',
|
||||
'TS send result',
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_send_js(text: str) -> str:
|
||||
text = replace_required(
|
||||
text,
|
||||
' const clientId = generateClientId();\n const req = buildSendMessageReq({',
|
||||
' const clientId = generateClientId();\n const createTimeMs = Date.now();\n const req = buildSendMessageReq({',
|
||||
'JS create time',
|
||||
)
|
||||
text = replace_required(
|
||||
text,
|
||||
' });\n try {\n await sendMessageApi({',
|
||||
' });\n if (!req.msg?.item_list?.[0])\n throw new Error("sendMessageWeixin: text item missing");\n req.msg.item_list[0].create_time_ms = createTimeMs;\n try {\n await sendMessageApi({',
|
||||
'JS request timestamp',
|
||||
)
|
||||
text = replace_required(
|
||||
text,
|
||||
' return { messageId: clientId };\n}\n/** Send a single structured MessageItem downstream. */',
|
||||
' return { messageId: clientId, createTimeMs };\n}\n/** Send a single structured MessageItem downstream. */',
|
||||
'JS send result',
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_process_ts(text: str) -> str:
|
||||
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
}});'''
|
||||
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
}});
|
||||
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||
text = replace_required(text, old, new, 'TS fallback text send')
|
||||
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
}});'''
|
||||
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
}});
|
||||
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||
return replace_required(text, old, new, 'TS normal text send')
|
||||
|
||||
|
||||
def patch_process_js(text: str) -> str:
|
||||
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
} });'''
|
||||
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
} });
|
||||
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||
text = replace_required(text, old, new, 'JS fallback text send')
|
||||
old = '''await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
} });'''
|
||||
new = '''const sentText = await sendMessageWeixin({ to: ctx.To, text, opts: {
|
||||
baseUrl: deps.baseUrl,
|
||||
token: deps.token,
|
||||
contextToken,
|
||||
runId,
|
||||
} });
|
||||
rememberHermesOutboundTextMessage(deps.accountId, ctx.To, text, sentText.createTimeMs);'''
|
||||
return replace_required(text, old, new, 'JS normal text send')
|
||||
|
||||
|
||||
def apply(root: Path) -> dict[str, bool]:
|
||||
transforms = {
|
||||
'src/messaging/send.ts': patch_send_ts,
|
||||
'dist/src/messaging/send.js': patch_send_js,
|
||||
'src/messaging/process-message.ts': patch_process_ts,
|
||||
'dist/src/messaging/process-message.js': patch_process_js,
|
||||
}
|
||||
changed = {}
|
||||
for relative, transform in transforms.items():
|
||||
path = root / relative
|
||||
original = path.read_text(encoding='utf-8')
|
||||
updated = transform(original)
|
||||
changed[relative] = updated != original
|
||||
if updated != original:
|
||||
path.write_text(updated, encoding='utf-8')
|
||||
return changed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('plugin_root', type=Path)
|
||||
args = parser.parse_args()
|
||||
root = args.plugin_root.resolve()
|
||||
print(json.dumps({'plugin_root': str(root), 'changed': apply(root)}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "deploy/v1r4c6-production-20260711",
|
||||
"base": "main",
|
||||
"created_by": "chatgpt-gitops-bot",
|
||||
"created_at": "2026-07-11T13:10:51Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://aios.local/schemas/automation-quorum-controller.schema.json",
|
||||
"title": "AIOS GPT plus one-executor quorum controller configuration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"database_path",
|
||||
"file_inbox_path",
|
||||
"health_receipts",
|
||||
"blocker_retry_threshold",
|
||||
"max_health_age_seconds",
|
||||
"lease_ttl_seconds",
|
||||
"gateway_event_url"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {"type": "string"},
|
||||
"database_path": {"type": "string", "pattern": "^/"},
|
||||
"file_inbox_path": {"type": "string", "pattern": "^/"},
|
||||
"default_workload": {
|
||||
"enum": [
|
||||
"large_repetitive_infrastructure_monitoring",
|
||||
"complex_code_security_architecture"
|
||||
]
|
||||
},
|
||||
"health_receipts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["central_gpt", "openclaw", "codex"],
|
||||
"properties": {
|
||||
"central_gpt": {"type": "string", "pattern": "^/"},
|
||||
"openclaw": {"type": "string", "pattern": "^/"},
|
||||
"codex": {"type": "string", "pattern": "^/"}
|
||||
}
|
||||
},
|
||||
"git_ref_polling": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["repository", "refs"],
|
||||
"properties": {
|
||||
"repository": {"type": "string", "pattern": "^/"},
|
||||
"refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^refs/"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"blocker_retry_threshold": {"type": "integer", "minimum": 1, "maximum": 20},
|
||||
"max_health_age_seconds": {"type": "integer", "minimum": 5, "maximum": 300},
|
||||
"lease_ttl_seconds": {"type": "integer", "minimum": 15, "maximum": 3600},
|
||||
"gateway_event_url": {"type": "string", "pattern": "^https?://"}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "root required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_ROOT=${1:?usage: install_automation_quorum_controller.sh SOURCE_ROOT VERSION}
|
||||
VERSION=${2:?usage: install_automation_quorum_controller.sh SOURCE_ROOT VERSION}
|
||||
[[ ${VERSION} =~ ^[0-9A-Za-z._-]+$ ]] || { echo "invalid version" >&2; exit 1; }
|
||||
SOURCE_REAL=$(readlink -f -- "${SOURCE_ROOT}" 2>/dev/null || true)
|
||||
[[ ${SOURCE_ROOT} = /* && -n ${SOURCE_REAL} && ${SOURCE_ROOT} == "${SOURCE_REAL}" && -d ${SOURCE_ROOT} && ! -L ${SOURCE_ROOT} ]] || {
|
||||
echo "unsafe source root" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
SOURCE_FILES=(
|
||||
automation/executor_quorum_controller.py
|
||||
schemas/automation-quorum-controller.schema.json
|
||||
config/aios-automation-quorum-controller.example.json
|
||||
systemd/aios-automation-quorum-controller.service
|
||||
systemd/aios-automation-quorum-controller.timer
|
||||
systemd/aios-automation-quorum-controller.sysusers
|
||||
systemd/aios-automation-quorum-controller.tmpfiles
|
||||
)
|
||||
for relative in "${SOURCE_FILES[@]}"; do
|
||||
[[ -f ${SOURCE_ROOT}/${relative} && ! -L ${SOURCE_ROOT}/${relative} ]] || {
|
||||
echo "unsafe source file: ${relative}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
INSTALL_ROOT=/opt/aios-automation-quorum
|
||||
VERSION_DIR=${INSTALL_ROOT}/releases/${VERSION}
|
||||
[[ ! -e ${VERSION_DIR} ]] || { echo "version already installed" >&2; exit 1; }
|
||||
|
||||
install -d -o root -g root -m 0755 "${VERSION_DIR}/bin" "${VERSION_DIR}/share"
|
||||
install -o root -g root -m 0755 "${SOURCE_ROOT}/automation/executor_quorum_controller.py" \
|
||||
"${VERSION_DIR}/bin/executor-quorum-controller"
|
||||
install -o root -g root -m 0644 "${SOURCE_ROOT}/schemas/automation-quorum-controller.schema.json" \
|
||||
"${VERSION_DIR}/share/automation-quorum-controller.schema.json"
|
||||
python3 -m py_compile "${VERSION_DIR}/bin/executor-quorum-controller"
|
||||
|
||||
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.service" /etc/systemd/system/
|
||||
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.timer" /etc/systemd/system/
|
||||
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.sysusers" /usr/lib/sysusers.d/aios-automation-quorum-controller.conf
|
||||
install -o root -g root -m 0644 "${SOURCE_ROOT}/systemd/aios-automation-quorum-controller.tmpfiles" /usr/lib/tmpfiles.d/aios-automation-quorum-controller.conf
|
||||
|
||||
systemd-sysusers /usr/lib/sysusers.d/aios-automation-quorum-controller.conf
|
||||
systemd-tmpfiles --create /usr/lib/tmpfiles.d/aios-automation-quorum-controller.conf
|
||||
install -d -o root -g aios-quorum -m 0750 /etc/aios-automation-quorum
|
||||
if [[ ! -e /etc/aios-automation-quorum/controller.json ]]; then
|
||||
install -o root -g aios-quorum -m 0640 \
|
||||
"${SOURCE_ROOT}/config/aios-automation-quorum-controller.example.json" \
|
||||
/etc/aios-automation-quorum/controller.json
|
||||
fi
|
||||
|
||||
PREVIOUS_TARGET=$(readlink -f "${INSTALL_ROOT}/current" 2>/dev/null || true)
|
||||
RECEIPT=${VERSION_DIR}/install-receipt.json
|
||||
python3 - "${VERSION_DIR}" "${VERSION}" "${PREVIOUS_TARGET}" "${RECEIPT}" <<'PY'
|
||||
import hashlib, json, os, pathlib, sys, tempfile
|
||||
root, version, previous, receipt = map(pathlib.Path, (sys.argv[1], sys.argv[2], sys.argv[3] or ".", sys.argv[4]))
|
||||
files = {}
|
||||
for item in sorted(root.rglob("*")):
|
||||
if item.is_file() and not item.is_symlink():
|
||||
files[str(item.relative_to(root))] = hashlib.sha256(item.read_bytes()).hexdigest()
|
||||
payload = {"version": str(version), "previous_target": "" if str(previous) == "." else str(previous), "files": files}
|
||||
fd, temp = tempfile.mkstemp(dir=str(receipt.parent), prefix=".receipt-")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, sort_keys=True, separators=(",", ":"))
|
||||
handle.write("\n")
|
||||
os.chmod(temp, 0o600)
|
||||
os.replace(temp, receipt)
|
||||
PY
|
||||
|
||||
ln -sfn "${VERSION_DIR}" "${INSTALL_ROOT}/.current-${VERSION}"
|
||||
mv -Tf "${INSTALL_ROOT}/.current-${VERSION}" "${INSTALL_ROOT}/current"
|
||||
|
||||
systemctl daemon-reload
|
||||
echo "installed=${VERSION_DIR}"
|
||||
echo "previous=${PREVIOUS_TARGET}"
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${EUID} -ne 0 ]]; then
|
||||
echo "root required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET=${1:?usage: rollback_automation_quorum_controller.sh ABSOLUTE_RELEASE_PATH}
|
||||
INSTALL_ROOT=/opt/aios-automation-quorum
|
||||
TARGET_REAL=$(readlink -f -- "${TARGET}" 2>/dev/null || true)
|
||||
[[ -n ${TARGET_REAL} && ${TARGET} == "${TARGET_REAL}" ]] || { echo "non-canonical or missing rollback target" >&2; exit 1; }
|
||||
case ${TARGET_REAL} in
|
||||
"${INSTALL_ROOT}"/releases/*) ;;
|
||||
*) echo "target outside release root" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ -d ${TARGET_REAL} && ! -L ${TARGET} ]] || { echo "invalid rollback target" >&2; exit 1; }
|
||||
|
||||
CURRENT=$(readlink -f "${INSTALL_ROOT}/current" 2>/dev/null || true)
|
||||
if [[ ${CURRENT} == "${TARGET_REAL}" ]]; then
|
||||
echo "already_current=${CURRENT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ln -sfn "${TARGET_REAL}" "${INSTALL_ROOT}/.rollback-current"
|
||||
mv -Tf "${INSTALL_ROOT}/.rollback-current" "${INSTALL_ROOT}/current"
|
||||
systemctl daemon-reload
|
||||
systemctl try-restart aios-automation-quorum-controller.timer
|
||||
echo "rolled_back_to=$(readlink -f "${INSTALL_ROOT}/current")"
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free, deterministic validation for the shipped controller config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
REQUIRED = {
|
||||
"database_path",
|
||||
"file_inbox_path",
|
||||
"health_receipts",
|
||||
"blocker_retry_threshold",
|
||||
"max_health_age_seconds",
|
||||
"lease_ttl_seconds",
|
||||
"gateway_event_url",
|
||||
}
|
||||
OPTIONAL = {"$schema", "default_workload", "git_ref_polling"}
|
||||
WORKLOADS = {
|
||||
"large_repetitive_infrastructure_monitoring",
|
||||
"complex_code_security_architecture",
|
||||
}
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def absolute_path(value: object, name: str) -> None:
|
||||
if not isinstance(value, str) or not value.startswith("/") or "\x00" in value:
|
||||
fail(f"{name} must be an absolute POSIX path")
|
||||
|
||||
|
||||
def bounded_integer(value: object, name: str, minimum: int, maximum: int) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||
fail(f"{name} must be an integer in [{minimum}, {maximum}]")
|
||||
|
||||
|
||||
def validate(config: object, schema: object) -> None:
|
||||
if not isinstance(config, dict) or not isinstance(schema, dict):
|
||||
fail("config and schema must be JSON objects")
|
||||
if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
|
||||
fail("unexpected JSON Schema dialect")
|
||||
if schema.get("additionalProperties") is not False:
|
||||
fail("schema must reject additional properties")
|
||||
if set(schema.get("required", [])) != REQUIRED:
|
||||
fail("schema required fields drifted from validator")
|
||||
keys = set(config)
|
||||
if not REQUIRED <= keys or keys - REQUIRED - OPTIONAL:
|
||||
fail("missing or unexpected top-level config fields")
|
||||
absolute_path(config["database_path"], "database_path")
|
||||
absolute_path(config["file_inbox_path"], "file_inbox_path")
|
||||
receipts = config["health_receipts"]
|
||||
if not isinstance(receipts, dict) or set(receipts) != {"central_gpt", "openclaw", "codex"}:
|
||||
fail("health_receipts must contain exactly central_gpt, openclaw, and codex")
|
||||
for subject, value in receipts.items():
|
||||
absolute_path(value, f"health_receipts.{subject}")
|
||||
git_polling = config.get("git_ref_polling")
|
||||
if git_polling is not None:
|
||||
if not isinstance(git_polling, dict) or set(git_polling) != {"repository", "refs"}:
|
||||
fail("git_ref_polling must contain exactly repository and refs")
|
||||
absolute_path(git_polling["repository"], "git_ref_polling.repository")
|
||||
refs = git_polling["refs"]
|
||||
if not isinstance(refs, list) or not refs or len(refs) != len(set(refs)):
|
||||
fail("git_ref_polling.refs must be a non-empty unique list")
|
||||
if any(not isinstance(ref, str) or not ref.startswith("refs/") or ".." in ref for ref in refs):
|
||||
fail("git_ref_polling.refs contains an invalid ref")
|
||||
if config.get("default_workload", "large_repetitive_infrastructure_monitoring") not in WORKLOADS:
|
||||
fail("unsupported default_workload")
|
||||
bounded_integer(config["blocker_retry_threshold"], "blocker_retry_threshold", 1, 20)
|
||||
bounded_integer(config["max_health_age_seconds"], "max_health_age_seconds", 5, 300)
|
||||
bounded_integer(config["lease_ttl_seconds"], "lease_ttl_seconds", 15, 3600)
|
||||
url = config["gateway_event_url"]
|
||||
parsed = urlparse(url) if isinstance(url, str) else None
|
||||
if not parsed or parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
fail("gateway_event_url must be an HTTP(S) URL with a host")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print(f"usage: {Path(sys.argv[0]).name} CONFIG SCHEMA", file=sys.stderr)
|
||||
return 2
|
||||
with Path(sys.argv[1]).open("r", encoding="utf-8") as handle:
|
||||
config = json.load(handle)
|
||||
with Path(sys.argv[2]).open("r", encoding="utf-8") as handle:
|
||||
schema = json.load(handle)
|
||||
try:
|
||||
validate(config, schema)
|
||||
except ValueError as error:
|
||||
print(f"schema validation failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
|
||||
|
||||
python3 -m py_compile "${ROOT}/automation/executor_quorum_controller.py"
|
||||
python3 -m unittest discover -s "${ROOT}/tests" -p 'test_executor_quorum_controller.py' -v
|
||||
python3 -m json.tool "${ROOT}/config/aios-automation-quorum-controller.example.json" >/dev/null
|
||||
python3 -m json.tool "${ROOT}/schemas/automation-quorum-controller.schema.json" >/dev/null
|
||||
python3 "${ROOT}/scripts/validate_automation_quorum_schema.py" \
|
||||
"${ROOT}/config/aios-automation-quorum-controller.example.json" \
|
||||
"${ROOT}/schemas/automation-quorum-controller.schema.json"
|
||||
python3 -m py_compile "${ROOT}/scripts/validate_automation_quorum_schema.py"
|
||||
bash -n "${ROOT}/scripts/install_automation_quorum_controller.sh"
|
||||
bash -n "${ROOT}/scripts/rollback_automation_quorum_controller.sh"
|
||||
bash -n "${ROOT}/scripts/verify_automation_quorum_controller.sh"
|
||||
systemd-analyze verify "${ROOT}/systemd/aios-automation-quorum-controller.service" "${ROOT}/systemd/aios-automation-quorum-controller.timer"
|
||||
@@ -0,0 +1,37 @@
|
||||
[Unit]
|
||||
Description=AIOS GPT plus one-executor quorum controller
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/aios-automation-quorum/controller.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=aios-quorum
|
||||
Group=aios-quorum
|
||||
ExecStart=/usr/bin/python3 /opt/aios-automation-quorum/current/bin/executor-quorum-controller --once --config /etc/aios-automation-quorum/controller.json
|
||||
UMask=0077
|
||||
NoNewPrivileges=yes
|
||||
PrivateDevices=yes
|
||||
PrivateTmp=yes
|
||||
ProtectClock=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHome=yes
|
||||
ProtectHostname=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/etc/aios-automation-quorum /run/aios-health
|
||||
ReadWritePaths=/var/lib/aios-automation-quorum
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1 @@
|
||||
u aios-quorum - "AIOS automation quorum controller" /var/lib/aios-automation-quorum /usr/sbin/nologin
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Poll AIOS executor quorum and recovery sources
|
||||
|
||||
[Timer]
|
||||
OnBootSec=30s
|
||||
OnUnitActiveSec=15s
|
||||
AccuracySec=1s
|
||||
Persistent=true
|
||||
Unit=aios-automation-quorum-controller.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,3 @@
|
||||
d /var/lib/aios-automation-quorum 0700 aios-quorum aios-quorum -
|
||||
d /var/lib/aios-automation-quorum/inbox 0700 aios-quorum aios-quorum -
|
||||
d /run/aios-health 0750 root aios-quorum -
|
||||
@@ -0,0 +1,390 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "automation" / "executor_quorum_controller.py"
|
||||
SPEC = importlib.util.spec_from_file_location("executor_quorum_controller", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
gateway = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = gateway
|
||||
SPEC.loader.exec_module(gateway)
|
||||
|
||||
|
||||
class QuorumDecisionTests(unittest.TestCase):
|
||||
def test_gpt_and_openclaw_two_unattended_cycles(self):
|
||||
for _ in range(2):
|
||||
decision = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY,
|
||||
gateway.Health.HEALTHY,
|
||||
gateway.Health.UNHEALTHY,
|
||||
"large_repetitive_infrastructure_monitoring",
|
||||
)
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual("openclaw", decision.active_executor)
|
||||
self.assertFalse(decision.repair_required)
|
||||
|
||||
def test_gpt_and_codex_two_unattended_cycles(self):
|
||||
for _ in range(2):
|
||||
decision = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY,
|
||||
gateway.Health.UNHEALTHY,
|
||||
gateway.Health.HEALTHY,
|
||||
"complex_code_security_architecture",
|
||||
)
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual("codex", decision.active_executor)
|
||||
|
||||
def test_both_healthy_route_by_workload(self):
|
||||
infrastructure = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.HEALTHY, gateway.Health.HEALTHY,
|
||||
"large_repetitive_infrastructure_monitoring",
|
||||
)
|
||||
code = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.HEALTHY, gateway.Health.HEALTHY,
|
||||
"complex_code_security_architecture",
|
||||
)
|
||||
self.assertEqual("openclaw", infrastructure.active_executor)
|
||||
self.assertEqual("codex", code.active_executor)
|
||||
|
||||
def test_standby_failure_does_not_block_or_request_repair(self):
|
||||
decision = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.HEALTHY, gateway.Health.UNHEALTHY, "architecture"
|
||||
)
|
||||
self.assertTrue(decision.allowed)
|
||||
self.assertEqual("P3", decision.priority)
|
||||
self.assertFalse(decision.repair_required)
|
||||
|
||||
def test_gpt_unhealthy_blocks_even_with_two_executors(self):
|
||||
decision = gateway.evaluate_quorum(
|
||||
gateway.Health.UNHEALTHY, gateway.Health.HEALTHY, gateway.Health.HEALTHY, "architecture"
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual("P0", decision.priority)
|
||||
|
||||
def test_gpt_unknown_blocks(self):
|
||||
decision = gateway.evaluate_quorum(
|
||||
gateway.Health.UNKNOWN, gateway.Health.HEALTHY, gateway.Health.HEALTHY, "architecture"
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn("UNKNOWN", decision.status)
|
||||
|
||||
def test_both_executors_unhealthy_block(self):
|
||||
decision = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.UNHEALTHY, gateway.Health.UNHEALTHY, "architecture"
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual("BLOCKED_NO_HEALTHY_EXECUTOR", decision.status)
|
||||
|
||||
def test_unknown_executor_does_not_count_as_healthy(self):
|
||||
blocked = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.UNKNOWN, gateway.Health.UNHEALTHY, "architecture"
|
||||
)
|
||||
allowed = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.UNKNOWN, gateway.Health.HEALTHY, "architecture"
|
||||
)
|
||||
self.assertFalse(blocked.allowed)
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertEqual("codex", allowed.active_executor)
|
||||
|
||||
def test_health_receipt_requires_live_decision_channel(self):
|
||||
base = {
|
||||
"subject": "central_gpt",
|
||||
"health": "HEALTHY",
|
||||
"live_probe_succeeded": True,
|
||||
"observed_at_epoch": 1,
|
||||
}
|
||||
self.assertEqual(gateway.Health.UNKNOWN, gateway.validate_health_receipt(base, "central_gpt", now=1))
|
||||
base["workspace_agent"] = {"validated_live": True}
|
||||
self.assertEqual(gateway.Health.HEALTHY, gateway.validate_health_receipt(base, "central_gpt", now=1))
|
||||
|
||||
def test_stale_health_receipt_is_unknown(self):
|
||||
receipt = {
|
||||
"subject": "codex", "health": "HEALTHY", "live_probe_succeeded": True,
|
||||
"observed_at_epoch": 1,
|
||||
}
|
||||
self.assertEqual(
|
||||
gateway.Health.UNKNOWN,
|
||||
gateway.validate_health_receipt(receipt, "codex", now=100, max_age_seconds=60),
|
||||
)
|
||||
|
||||
def test_workspace_fallback_requires_validated_responses(self):
|
||||
receipt = {
|
||||
"subject": "central_gpt", "health": "HEALTHY", "live_probe_succeeded": True,
|
||||
"observed_at_epoch": 10, "responses_api": {"validated_live": True},
|
||||
}
|
||||
unvalidated = dict(receipt, responses_api={"configured": True})
|
||||
self.assertFalse(gateway.responses_fallback_allowed(False, unvalidated, now=10))
|
||||
self.assertTrue(gateway.responses_fallback_allowed(False, receipt, now=10))
|
||||
self.assertFalse(gateway.responses_fallback_allowed(True, receipt, now=10))
|
||||
|
||||
|
||||
class StoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
os.chmod(self.root, 0o700)
|
||||
self.database = self.root / "controller.sqlite3"
|
||||
self.store = gateway.QuorumStore(self.database)
|
||||
self.decision_openclaw = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.HEALTHY, gateway.Health.UNHEALTHY,
|
||||
"large_repetitive_infrastructure_monitoring",
|
||||
)
|
||||
self.decision_codex = gateway.evaluate_quorum(
|
||||
gateway.Health.HEALTHY, gateway.Health.UNHEALTHY, gateway.Health.HEALTHY,
|
||||
"complex_code_security_architecture",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.store.close()
|
||||
self.temporary.cleanup()
|
||||
|
||||
def create_task(self):
|
||||
self.store.ingest_event("event-1", {"work_unit": "WU-1"}, "http")
|
||||
return self.store.upsert_task(
|
||||
work_unit="WU-1",
|
||||
contract_commit="c" * 40,
|
||||
event_id="event-1",
|
||||
state_version=1,
|
||||
workload="large_repetitive_infrastructure_monitoring",
|
||||
decision=self.decision_openclaw,
|
||||
checkpoint="checkpoint-7",
|
||||
)
|
||||
|
||||
def test_same_event_id_same_payload_is_idempotent(self):
|
||||
self.assertTrue(self.store.ingest_event("event", {"value": 1}, "http"))
|
||||
self.assertFalse(self.store.ingest_event("event", {"value": 1}, "file-inbox"))
|
||||
count = self.store.db.execute("SELECT COUNT(*) AS n FROM events").fetchone()["n"]
|
||||
self.assertEqual(1, count)
|
||||
|
||||
def test_same_dispatch_is_not_emitted_twice(self):
|
||||
self.store.ingest_event("event-3", {"work_unit": "WU-3"}, "http")
|
||||
kwargs = dict(
|
||||
work_unit="WU-3", contract_commit="e" * 40, event_id="event-3", state_version=1,
|
||||
workload="large_repetitive_infrastructure_monitoring", decision=self.decision_openclaw,
|
||||
)
|
||||
self.store.dispatch_task(**kwargs)
|
||||
self.store.dispatch_task(**kwargs)
|
||||
lifecycle = self.store.db.execute(
|
||||
"SELECT COUNT(*) AS n FROM lifecycle WHERE work_unit='WU-3' AND event_type='TASK_DISPATCHED'"
|
||||
).fetchone()["n"]
|
||||
outbox = self.store.db.execute(
|
||||
"SELECT COUNT(*) AS n FROM outbox WHERE outbox_id LIKE 'dispatch:WU-3:%'"
|
||||
).fetchone()["n"]
|
||||
self.assertEqual(1, lifecycle)
|
||||
self.assertEqual(1, outbox)
|
||||
|
||||
def test_same_event_id_different_payload_dead_letters(self):
|
||||
self.store.ingest_event("event", {"value": 1}, "http")
|
||||
with self.assertRaises(gateway.EventConflict):
|
||||
self.store.ingest_event("event", {"value": 2}, "file-inbox")
|
||||
status = self.store.db.execute("SELECT status FROM events WHERE event_id='event'").fetchone()["status"]
|
||||
self.assertEqual("DEAD_LETTER_CONFLICT", status)
|
||||
|
||||
def test_stale_state_version_rejected(self):
|
||||
self.create_task()
|
||||
with self.assertRaises(gateway.StaleStateVersion):
|
||||
self.store.upsert_task(
|
||||
work_unit="WU-1", contract_commit="c" * 40, event_id="event-1", state_version=0,
|
||||
workload="architecture", decision=self.decision_codex,
|
||||
)
|
||||
|
||||
def test_two_workers_compete_for_lease(self):
|
||||
self.create_task()
|
||||
self.assertTrue(self.store.claim_lease("WU-1", "openclaw", 30, now=100))
|
||||
self.assertFalse(self.store.claim_lease("WU-1", "codex", 30, now=101))
|
||||
|
||||
def test_stale_lease_is_recovered(self):
|
||||
self.create_task()
|
||||
self.assertTrue(self.store.claim_lease("WU-1", "openclaw", 5, now=100))
|
||||
self.store.upsert_task(
|
||||
work_unit="WU-1", contract_commit="c" * 40, event_id="event-1", state_version=1,
|
||||
workload="complex_code_security_architecture", decision=self.decision_codex,
|
||||
)
|
||||
self.assertTrue(self.store.claim_lease("WU-1", "codex", 5, now=106))
|
||||
|
||||
def test_active_failover_preserves_identity_checkpoint_and_effect(self):
|
||||
self.create_task()
|
||||
self.assertTrue(self.store.record_effect("effect-1", "WU-1", {"kind": "write_ref", "oid": "a" * 40}))
|
||||
status = self.store.upsert_task(
|
||||
work_unit="WU-1", contract_commit="c" * 40, event_id="event-1", state_version=1,
|
||||
workload="complex_code_security_architecture", decision=self.decision_codex,
|
||||
)
|
||||
task = self.store.db.execute("SELECT * FROM tasks WHERE work_unit='WU-1'").fetchone()
|
||||
self.assertEqual("INTERRUPTED_AGENT_FAILOVER", status)
|
||||
self.assertEqual("openclaw", task["interrupted_executor"])
|
||||
self.assertEqual("codex", task["active_executor"])
|
||||
self.assertEqual("P2", task["recovery_priority"])
|
||||
self.assertEqual("event-1", task["event_id"])
|
||||
self.assertEqual("c" * 40, task["contract_commit"])
|
||||
self.assertEqual("checkpoint-7", task["checkpoint"])
|
||||
self.assertFalse(self.store.record_effect("effect-1", "WU-1", {"kind": "write_ref", "oid": "a" * 40}))
|
||||
|
||||
def test_lifecycle_and_summary_persist_without_openclaw(self):
|
||||
self.create_task()
|
||||
for event_type in (
|
||||
"TASK_DISPATCHED", "TASK_STARTED", "TASK_ENDED", "DECISION_APPLIED",
|
||||
"PROJECT_SUMMARY", "PROJECT_COMPLETED",
|
||||
):
|
||||
self.store.append_lifecycle("WU-1", event_type, "codex", 1, {"result": "PASS"})
|
||||
self.store.persist_summary("WU-1", {"executor": "codex", "result": "PASS"})
|
||||
events = self.store.db.execute("SELECT COUNT(*) AS n FROM lifecycle").fetchone()["n"]
|
||||
summaries = self.store.db.execute("SELECT COUNT(*) AS n FROM summaries").fetchone()["n"]
|
||||
self.assertEqual(6, events)
|
||||
self.assertEqual(1, summaries)
|
||||
|
||||
def test_lifecycle_and_summary_persist_without_codex(self):
|
||||
self.create_task()
|
||||
self.store.append_lifecycle("WU-1", "TASK_STARTED", "openclaw", 1, {"result": "RUNNING"})
|
||||
self.store.append_lifecycle("WU-1", "TASK_ENDED", "openclaw", 1, {"result": "PASS"})
|
||||
self.store.persist_summary("WU-1", {"executor": "openclaw", "result": "PASS"})
|
||||
self.assertEqual(
|
||||
"openclaw",
|
||||
json.loads(self.store.db.execute("SELECT summary_json FROM summaries").fetchone()["summary_json"])["executor"],
|
||||
)
|
||||
|
||||
def test_restart_recovers_event_lease_and_outbox(self):
|
||||
self.create_task()
|
||||
self.store.claim_lease("WU-1", "openclaw", 5, now=100)
|
||||
self.store.enqueue("outbox-1", "gateway", {"event_type": "TASK_STARTED"})
|
||||
self.store.close()
|
||||
self.store = gateway.QuorumStore(self.database)
|
||||
self.assertEqual(1, self.store.db.execute("SELECT COUNT(*) AS n FROM events").fetchone()["n"])
|
||||
self.assertEqual(1, self.store.db.execute("SELECT COUNT(*) AS n FROM outbox").fetchone()["n"])
|
||||
self.assertTrue(self.store.claim_lease("WU-1", "openclaw", 5, now=106))
|
||||
|
||||
def test_lifecycle_helpers_enforce_active_executor_and_complete(self):
|
||||
self.store.ingest_event("event-2", {"work_unit": "WU-2"}, "http")
|
||||
self.store.dispatch_task(
|
||||
work_unit="WU-2", contract_commit="d" * 40, event_id="event-2", state_version=3,
|
||||
workload="complex_code_security_architecture", decision=self.decision_codex,
|
||||
)
|
||||
self.assertFalse(self.store.start_task("WU-2", "openclaw", 3, 30))
|
||||
self.assertTrue(self.store.start_task("WU-2", "codex", 3, 30))
|
||||
self.store.end_task(
|
||||
"WU-2", "codex", 3, "PASS", {"executor": "codex", "result": "PASS"}, project_completed=True,
|
||||
)
|
||||
task = self.store.db.execute("SELECT status,lease_owner FROM tasks WHERE work_unit='WU-2'").fetchone()
|
||||
self.assertEqual("COMPLETED", task["status"])
|
||||
self.assertIsNone(task["lease_owner"])
|
||||
event_types = [row["event_type"] for row in self.store.db.execute("SELECT event_type FROM lifecycle ORDER BY sequence")]
|
||||
self.assertEqual(
|
||||
["TASK_DISPATCHED", "TASK_STARTED", "TASK_ENDED", "DECISION_APPLIED", "PROJECT_SUMMARY", "PROJECT_COMPLETED"],
|
||||
event_types,
|
||||
)
|
||||
self.assertEqual(6, self.store.db.execute("SELECT COUNT(*) AS n FROM outbox").fetchone()["n"])
|
||||
|
||||
def test_blocker_threshold_enters_dead_letter(self):
|
||||
self.assertEqual("RETRY", self.store.record_blocker("same", 2))
|
||||
self.assertEqual("RETRY", self.store.record_blocker("same", 2))
|
||||
self.assertEqual("DEAD_LETTER", self.store.record_blocker("same", 2))
|
||||
|
||||
def test_outbox_retries_then_dead_letters_and_success_is_persisted(self):
|
||||
self.store.enqueue("failure", "gateway", {"event_type": "TASK_STARTED"})
|
||||
self.store.enqueue("success", "gateway", {"event_type": "TASK_ENDED"})
|
||||
|
||||
def sender(payload):
|
||||
if payload["event_type"] == "TASK_STARTED":
|
||||
raise OSError("gateway unavailable")
|
||||
|
||||
now = time.time() + 1
|
||||
self.assertEqual((1, 1), self.store.deliver_outbox(sender, max_attempts=2, now=now))
|
||||
self.assertEqual((0, 1), self.store.deliver_outbox(sender, max_attempts=2, now=now + 3))
|
||||
statuses = {
|
||||
row["outbox_id"]: row["status"] for row in self.store.db.execute("SELECT outbox_id,status FROM outbox")
|
||||
}
|
||||
self.assertEqual("DEAD_LETTER", statuses["failure"])
|
||||
self.assertEqual("SENT", statuses["success"])
|
||||
|
||||
def test_git_ref_polling_detects_missed_webhook(self):
|
||||
self.assertFalse(self.store.observe_ref("refs/heads/main", "a" * 40))
|
||||
self.assertTrue(self.store.observe_ref("refs/heads/main", "b" * 40))
|
||||
self.assertFalse(self.store.observe_ref("refs/heads/main", "b" * 40))
|
||||
|
||||
def test_git_ref_poller_creates_one_event_per_change(self):
|
||||
values = iter(("a" * 40, "b" * 40, "b" * 40))
|
||||
|
||||
def resolver(_repo, _ref):
|
||||
return next(values)
|
||||
|
||||
self.assertEqual(0, gateway.poll_git_refs(self.store, Path("/repo"), ["refs/heads/main"], resolver))
|
||||
self.assertEqual(1, gateway.poll_git_refs(self.store, Path("/repo"), ["refs/heads/main"], resolver))
|
||||
self.assertEqual(0, gateway.poll_git_refs(self.store, Path("/repo"), ["refs/heads/main"], resolver))
|
||||
self.assertEqual(1, self.store.db.execute("SELECT COUNT(*) AS n FROM events").fetchone()["n"])
|
||||
|
||||
|
||||
class RecoveryAndSafetyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
os.chmod(self.root, 0o700)
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_http_failure_spools_then_file_poll_recovers(self):
|
||||
inbox = self.root / "inbox"
|
||||
inbox.mkdir(mode=0o700)
|
||||
store = gateway.QuorumStore(self.root / "store.sqlite3")
|
||||
try:
|
||||
def fail(_payload):
|
||||
raise OSError("network unavailable")
|
||||
|
||||
self.assertFalse(gateway.spool_after_http_failure(fail, {"kind": "dispatch"}, inbox, "event-1"))
|
||||
self.assertEqual(1, gateway.poll_file_inbox(store, inbox))
|
||||
self.assertEqual(1, store.db.execute("SELECT COUNT(*) AS n FROM events").fetchone()["n"])
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX mode semantics required")
|
||||
def test_symlink_and_writable_parent_are_fail_closed(self):
|
||||
safe = self.root / "safe"
|
||||
safe.mkdir(mode=0o700)
|
||||
receipt = safe / "receipt.json"
|
||||
receipt.write_text('{"subject":"codex"}', encoding="utf-8")
|
||||
receipt.chmod(0o600)
|
||||
link = safe / "link.json"
|
||||
link.symlink_to(receipt)
|
||||
with self.assertRaises(gateway.ControllerError):
|
||||
gateway.read_secure_json(link)
|
||||
safe.chmod(0o777)
|
||||
with self.assertRaises(gateway.ControllerError):
|
||||
gateway.read_secure_json(receipt)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX ownership and symlink semantics required")
|
||||
def test_database_symlink_is_fail_closed(self):
|
||||
safe = self.root / "safe-db"
|
||||
safe.mkdir(mode=0o700)
|
||||
target = safe / "target.sqlite3"
|
||||
target.write_bytes(b"")
|
||||
target.chmod(0o600)
|
||||
link = safe / "controller.sqlite3"
|
||||
link.symlink_to(target)
|
||||
with self.assertRaises(gateway.ControllerError):
|
||||
gateway.QuorumStore(link)
|
||||
|
||||
def test_secret_and_process_output_fields_rejected(self):
|
||||
for payload in ({"token": "x"}, {"nested": {"stderr": "x"}}, {"env": {"X": "x"}}):
|
||||
with self.assertRaises(gateway.ControllerError):
|
||||
gateway.validate_metadata_only(payload)
|
||||
|
||||
def test_invalid_lifecycle_event_rejected(self):
|
||||
store = gateway.QuorumStore(self.root / "store.sqlite3")
|
||||
try:
|
||||
with self.assertRaises(gateway.ControllerError):
|
||||
store.append_lifecycle("WU", "UNAPPROVED", None, 1, {})
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_bigint.py'
|
||||
|
||||
|
||||
def load_patcher():
|
||||
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_bigint', PATCHER_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_api_patch_is_idempotent_and_scoped() -> None:
|
||||
patcher = load_patcher()
|
||||
ts = '\n'.join([
|
||||
'import { fileURLToPath } from "node:url";',
|
||||
'import { redactBody, redactUrl } from "../util/redact.js";',
|
||||
'const resp: GetUpdatesResp = JSON.parse(rawText);',
|
||||
])
|
||||
js = '\n'.join([
|
||||
'import { fileURLToPath } from "node:url";',
|
||||
'import { redactBody, redactUrl } from "../util/redact.js";',
|
||||
'const resp = JSON.parse(rawText);',
|
||||
])
|
||||
types = 'export interface WeixinMessage {\n message_id?: number;\n}\n'
|
||||
|
||||
ts_once = patcher.patch_api_ts(ts)
|
||||
js_once = patcher.patch_api_js(js)
|
||||
types_once = patcher.patch_types_ts(types)
|
||||
|
||||
assert patcher.patch_api_ts(ts_once) == ts_once
|
||||
assert patcher.patch_api_js(js_once) == js_once
|
||||
assert patcher.patch_types_ts(types_once) == types_once
|
||||
assert 'parseWeixinGetUpdatesJson(rawText)' in ts_once
|
||||
assert 'parseWeixinGetUpdatesJson(rawText)' in js_once
|
||||
assert 'message_id?: string | number;' in types_once
|
||||
|
||||
|
||||
def test_node_reviver_preserves_only_weixin_exact_ids() -> None:
|
||||
script = '''
|
||||
const ids = new Set(["message_id", "msg_id", "root_id", "parent_id", "server_msg_id", "new_msg_id"]);
|
||||
const parsed = JSON.parse('{"ret":0,"seq":42,"msgs":[{"message_id":7482038281766381234,"create_time_ms":1783856936191,"item_list":[{"msg_id":"3123141975110186863"}]}]}', (key, value, context) => {
|
||||
if (!ids.has(key) || value == null || typeof value === "string") return value;
|
||||
if (typeof value !== "number" || !context?.source || !/^(?:0|[1-9]\\d*)$/.test(context.source)) throw new Error("unsafe id");
|
||||
return context.source;
|
||||
});
|
||||
console.log(JSON.stringify({ messageId: parsed.msgs[0].message_id, msgId: parsed.msgs[0].item_list[0].msg_id, seqType: typeof parsed.seq, timestampType: typeof parsed.msgs[0].create_time_ms }));
|
||||
'''
|
||||
completed = subprocess.run(['node', '-e', script], check=True, capture_output=True, text=True)
|
||||
result = json.loads(completed.stdout)
|
||||
assert result == {
|
||||
'messageId': '7482038281766381234',
|
||||
'msgId': '3123141975110186863',
|
||||
'seqType': 'number',
|
||||
'timestampType': 'number',
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def load_js_process_helpers() -> str:
|
||||
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
|
||||
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
|
||||
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.JS_PROCESS_HELPERS
|
||||
|
||||
|
||||
|
||||
def load_patcher_module() -> object:
|
||||
patcher_path = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_hermes.py'
|
||||
assert patcher_path.exists(), f'missing patcher: {patcher_path}'
|
||||
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_hermes', patcher_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
|
||||
def run_js_process_helpers(tmp_path: Path, body: str) -> dict:
|
||||
js_process_helpers = load_js_process_helpers()
|
||||
node_prelude = '\n'.join(
|
||||
[
|
||||
'const hermesTestFs = require("node:fs");',
|
||||
'globalThis.existsSync = hermesTestFs.existsSync;',
|
||||
'globalThis.readFileSync = hermesTestFs.readFileSync;',
|
||||
'globalThis.writeFileSync = hermesTestFs.writeFileSync;',
|
||||
'globalThis.mkdirSync = hermesTestFs.mkdirSync;',
|
||||
'globalThis.appendFileSync = hermesTestFs.appendFileSync;',
|
||||
'globalThis.path = require("node:path");',
|
||||
'globalThis.logger = { info() {}, warn() {}, error() {} };',
|
||||
]
|
||||
)
|
||||
node_script = tmp_path / 'hermes_image_followup_check.js'
|
||||
node_script.write_text(node_prelude + '\n\n' + js_process_helpers + '\n\n' + body, encoding='utf-8')
|
||||
completed = subprocess.run(['node', str(node_script)], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('is_ts', [True, False], ids=['ts', 'js'])
|
||||
def test_replace_or_insert_process_helpers_is_byte_idempotent(is_ts: bool) -> None:
|
||||
patcher = load_patcher_module()
|
||||
marker = 'export async function processOneMessage'
|
||||
source = marker + '(message) {\n return message;\n}\n'
|
||||
|
||||
once = patcher.replace_or_insert_process_helpers(source, is_ts=is_ts)
|
||||
twice = patcher.replace_or_insert_process_helpers(once, is_ts=is_ts)
|
||||
helpers = patcher.TS_PROCESS_HELPERS if is_ts else patcher.JS_PROCESS_HELPERS
|
||||
|
||||
assert helpers + '\n\n' + marker in once
|
||||
assert twice == once
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('is_ts', 'hook_name'),
|
||||
[(True, 'TS_PROCESS_HOOK'), (False, 'JS_PROCESS_HOOK')],
|
||||
ids=['ts', 'js'],
|
||||
)
|
||||
def test_remove_and_reinsert_process_hook_is_byte_idempotent(is_ts: bool, hook_name: str) -> None:
|
||||
patcher = load_patcher_module()
|
||||
hook = getattr(patcher, hook_name)
|
||||
indent = ' ' if is_ts else ' '
|
||||
needle = indent + 'ctx.CommandAuthorized = commandAuthorized;\n'
|
||||
next_nonblank = indent + 'continueProcessing();\n'
|
||||
|
||||
def cycle(text: str) -> str:
|
||||
without_hook = patcher.remove_existing_process_hermes_hook(text)
|
||||
return without_hook.replace(needle, needle + hook + '\n', 1)
|
||||
|
||||
once = cycle(needle + next_nonblank)
|
||||
twice = cycle(once)
|
||||
|
||||
assert hook + '\n' + next_nonblank in once
|
||||
assert twice == once
|
||||
|
||||
|
||||
|
||||
def test_looks_like_hermes_image_followup_distinguishes_diagnostics_from_real_image_questions(tmp_path: Path) -> None:
|
||||
cases = {
|
||||
'diag_cache_expired': ('\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
|
||||
'diag_cache_error_en': ('image cache error', False),
|
||||
'diag_cache_expired_en': ('the image cache expired, please resend', False),
|
||||
'diag_copying_text_shows_cache_error': ('\u4e3a\u4ec0\u4e48\u6211\u590d\u5236\u53d1\u7ed9\u4f60\u7684\u6587\u5b57\uff0c\u4f60\u90fd\u663e\u793a\u56fe\u7247\u7f13\u5b58\u5df2\u5931\u6548\uff0c\u8bf7\u91cd\u65b0\u53d1\u9001\u56fe\u7247\u3002', False),
|
||||
'diag_not_image': ('\u8fd9\u4e0d\u662f\u56fe\u7247\uff0c\u662f\u6587\u5b57\u6d88\u606f', False),
|
||||
'diag_text_not_image_en': ('this is a text message, not an image', False),
|
||||
'diag_no_image_sent': ('\u6211\u6ca1\u6709\u53d1\u56fe\u7247', False),
|
||||
'diag_system_logs': ('\u8bf7\u68c0\u67e5\u7cfb\u7edf\u65e5\u5fd7\uff0c\u4ece\u673a\u5236\u4e0a\u4fee\u590d\u9519\u8bef', False),
|
||||
'image_cn_1': ('\u8bf7\u770b\u4e00\u4e0b\u8fd9\u5f20\u56fe\u7247', True),
|
||||
'image_cn_2': ('\u8fd9\u56fe\u662f\u4ec0\u4e48', True),
|
||||
'image_cn_3': ('\u8bc6\u522b\u56fe\u7247\u5185\u5bb9', True),
|
||||
'image_en': ('look at this image', True),
|
||||
}
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'const cases = '
|
||||
+ json.dumps({key: text for key, (text, _) in cases.items()}, ensure_ascii=False)
|
||||
+ ';\nconst results = {};\n'
|
||||
+ 'for (const [key, text] of Object.entries(cases)) {\n'
|
||||
+ ' results[key] = looksLikeHermesImageFollowup(text);\n'
|
||||
+ '}\n'
|
||||
+ 'console.log(JSON.stringify(results));\n',
|
||||
)
|
||||
for key, (_, expected) in cases.items():
|
||||
assert results[key] is expected, f'{key}: expected {expected}, got {results[key]!r}'
|
||||
|
||||
|
||||
|
||||
def test_resolve_hermes_referenced_image_followup_requires_exact_cache(tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / 'hermes-last-image-cache.json'
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const fs = require("fs");',
|
||||
f'const cachePath = {json.dumps(str(cache_path))};',
|
||||
'const accountId = "account-a";',
|
||||
'const senderId = "sender-b";',
|
||||
'const exactQuotedId = "quoted-123";',
|
||||
'const cache = {};',
|
||||
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
|
||||
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||
' timestampMs: Date.now(),',
|
||||
' messageId: "latest-999",',
|
||||
' mediaType: "image/png",',
|
||||
'};',
|
||||
'cache[hermesExactImageCacheKey(accountId, senderId, exactQuotedId)] = {',
|
||||
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||
' timestampMs: Date.now(),',
|
||||
' messageId: exactQuotedId,',
|
||||
' mediaType: "image/png",',
|
||||
'};',
|
||||
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
|
||||
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
|
||||
'const generic = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", "quoted-999");',
|
||||
'const exact = resolveHermesReferencedImageFollowup(accountId, senderId, "plain quoted text", exactQuotedId);',
|
||||
'const forced = resolveHermesFollowupImageContext(accountId, senderId, "plain quoted text", exactQuotedId, false, true);',
|
||||
'console.log(JSON.stringify({ generic, exact, forced }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['generic']['imageIntent'] is False
|
||||
assert results['generic']['matched'] is False
|
||||
assert results['generic']['reason'] == 'referenced_image_not_confirmed'
|
||||
assert results['exact']['imageIntent'] is True
|
||||
assert results['exact']['matched'] is True
|
||||
assert results['exact']['reason'] == 'cached_image_matched'
|
||||
assert results['forced']['imageIntent'] is True
|
||||
assert results['forced']['matched'] is True
|
||||
|
||||
|
||||
|
||||
def test_referenced_text_without_media_evidence_stays_out_of_image_cache(tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / 'hermes-last-image-cache.json'
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const fs = require("fs");',
|
||||
f'const cachePath = {json.dumps(str(cache_path))};',
|
||||
'const accountId = "account-a";',
|
||||
'const senderId = "sender-b";',
|
||||
'const cache = {};',
|
||||
'cache[hermesLastImageCacheKey(accountId, senderId)] = {',
|
||||
' mediaUrl: "data:image/png;base64,iVBORw0KGgo=",',
|
||||
' timestampMs: Date.now(),',
|
||||
' messageId: "latest-999",',
|
||||
' mediaType: "image/png",',
|
||||
'};',
|
||||
'fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), "utf-8");',
|
||||
'process.env.AIOS_HERMES_WEIXIN_LAST_IMAGE_CACHE = cachePath;',
|
||||
'const full = { item_list: [{ ref_msg: { message_item: { type: 0, id: "quoted-999" } } }] };',
|
||||
'const hermesContent = "\u8fd9\u662f\u4ec0\u4e48";',
|
||||
'const hermesReferenceContextPresent = hasHermesReferenceContext(full.item_list);',
|
||||
'const hermesQuotedMessageId = findHermesReferencedMessageId(full.item_list);',
|
||||
'const refMediaItem = undefined;',
|
||||
'const hermesReferencedMediaIsImage = Boolean(refMediaItem && hermesCurrentMessageIsImage({}, refMediaItem));',
|
||||
'const hermesReferencedImageFollowupIntent = hermesReferenceContextPresent && !refMediaItem;',
|
||||
'const hermesCurrentImage = false;',
|
||||
'const hermesFollowup = hermesCurrentImage',
|
||||
' ? { imageIntent: false, matched: false, reason: "current_image_message", cacheStrategy: "current", quotedMessageId: hermesQuotedMessageId }',
|
||||
' : (hermesReferencedMediaIsImage',
|
||||
' ? resolveHermesFollowupImageContext(accountId, senderId, hermesContent, hermesQuotedMessageId, false, true)',
|
||||
' : (hermesReferencedImageFollowupIntent',
|
||||
' ? resolveHermesReferencedImageFollowup(accountId, senderId, hermesContent, hermesQuotedMessageId)',
|
||||
' : resolveHermesFollowupImageContext(accountId, senderId, hermesContent)));',
|
||||
'console.log(JSON.stringify({ hermesReferenceContextPresent, hermesQuotedMessageId, hermesReferencedMediaIsImage, hermesReferencedImageFollowupIntent, hermesFollowup }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['hermesReferenceContextPresent'] is True
|
||||
assert results['hermesQuotedMessageId'] == 'quoted-999'
|
||||
assert results['hermesReferencedMediaIsImage'] is False
|
||||
assert results['hermesReferencedImageFollowupIntent'] is True
|
||||
assert results['hermesFollowup']['imageIntent'] is False
|
||||
assert results['hermesFollowup']['matched'] is False
|
||||
assert results['hermesFollowup']['reason'] == 'referenced_image_not_confirmed'
|
||||
|
||||
|
||||
def test_explicit_quoted_text_builds_grounded_plain_text_context(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const available = typeof resolveHermesQuotedTextContext === "function";',
|
||||
'const items = [{ type: 1, ref_msg: { message_item: { type: 1, text_item: { text: "被引用的正文" } } } }];',
|
||||
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
|
||||
'console.log(JSON.stringify({ available, result }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['available'] is True
|
||||
assert results['result'] == {
|
||||
'referenced': True,
|
||||
'handled': False,
|
||||
'prompt': '[被引用文字]\n被引用的正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||
'reason': 'quoted_text_resolved',
|
||||
}
|
||||
|
||||
|
||||
def test_visible_ref_title_builds_grounded_plain_text_context(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const items = [{ type: 1, ref_msg: { title: "客户端可见的引用正文", message_item: { type: 0, msg_id: "quoted-123" } } }];',
|
||||
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
|
||||
'console.log(JSON.stringify(result));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results == {
|
||||
'referenced': True,
|
||||
'handled': False,
|
||||
'prompt': '[被引用文字]\n客户端可见的引用正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||
'reason': 'quoted_text_resolved',
|
||||
}
|
||||
|
||||
|
||||
def test_nested_generic_title_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, title: "非协议引用摘要" } } }];',
|
||||
'const result = resolveHermesQuotedTextContext(items, "这是什么意思?");',
|
||||
'console.log(JSON.stringify(result));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results == {
|
||||
'referenced': True,
|
||||
'handled': True,
|
||||
'replyText': '无法读取被引用文字,请复制原文后再提问。',
|
||||
'reason': 'quoted_text_unavailable',
|
||||
}
|
||||
|
||||
|
||||
def test_exact_message_id_cache_resolves_server_reference_without_guessing(tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||
'const accountId = "account-a";',
|
||||
'const senderId = "sender-b";',
|
||||
'const original = { item_list: [{ type: 1, msg_id: "7481729105240418312", text_item: { text: "客户端已经显示的原始正文" } }] };',
|
||||
'rememberHermesTextMessage(accountId, senderId, original, "客户端已经显示的原始正文");',
|
||||
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } }, text_item: { text: "这是什么意思?" } }];',
|
||||
'const quotedId = findHermesReferencedMessageId(quotedItems);',
|
||||
'const exact = resolveHermesCachedQuotedText(accountId, senderId, quotedId);',
|
||||
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "7481729105240418313");',
|
||||
'const resolved = resolveHermesQuotedTextContext(quotedItems, "这是什么意思?", exact);',
|
||||
'const mode = (hermesTestFs.statSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE).mode & 0o777).toString(8);',
|
||||
'console.log(JSON.stringify({ quotedId, exact, wrong, resolved, mode }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['quotedId'] == '7481729105240418312'
|
||||
assert results['exact'] == '客户端已经显示的原始正文'
|
||||
assert results['wrong'] == ''
|
||||
assert results['resolved'] == {
|
||||
'referenced': True,
|
||||
'handled': False,
|
||||
'prompt': '[被引用文字]\n客户端已经显示的原始正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||
'reason': 'quoted_text_resolved',
|
||||
}
|
||||
assert results['mode'] == '600'
|
||||
|
||||
|
||||
def test_expired_message_id_cache_entry_is_not_resolved(tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||
'const key = hermesTextMessageCacheKey("account-a", "sender-b", "quoted-old");',
|
||||
'hermesTestFs.writeFileSync(process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE, JSON.stringify({ [key]: { text: "过期正文", timestampMs: Date.now() - (8 * 24 * 60 * 60 * 1000) } }), { encoding: "utf-8", mode: 0o600 });',
|
||||
'const result = resolveHermesCachedQuotedText("account-a", "sender-b", "quoted-old");',
|
||||
'console.log(JSON.stringify({ result }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['result'] == ''
|
||||
|
||||
|
||||
def test_unsafe_numeric_message_id_is_rejected_before_cache_keying(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const exactString = extractHermesMessageId({ message_id: "7482038281766381234" });',
|
||||
'const unsafeNumber = extractHermesMessageId({ message_id: 7482038281766381234 });',
|
||||
'const safeNumber = extractHermesMessageId({ message_id: 12345 });',
|
||||
'console.log(JSON.stringify({ exactString, unsafeNumber: unsafeNumber ?? null, safeNumber }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results == {
|
||||
'exactString': '7482038281766381234',
|
||||
'unsafeNumber': None,
|
||||
'safeNumber': '12345',
|
||||
}
|
||||
|
||||
|
||||
def test_exact_outbound_create_time_resolves_quoted_bot_text(tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||
'const accountId = "account-a";',
|
||||
'const senderId = "sender-b";',
|
||||
'const createTimeMs = 1783857900123;',
|
||||
'rememberHermesOutboundTextMessage(accountId, senderId, "机器人出站正文", createTimeMs);',
|
||||
'const quotedItems = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "server-quoted", create_time_ms: createTimeMs } }, text_item: { text: "进展汇报" } }];',
|
||||
'const referencedTime = findHermesReferencedCreateTimeMs(quotedItems);',
|
||||
'const exact = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", referencedTime);',
|
||||
'const wrong = resolveHermesCachedQuotedText(accountId, senderId, "server-quoted", createTimeMs + 1);',
|
||||
'const resolved = resolveHermesQuotedTextContext(quotedItems, "进展汇报", exact);',
|
||||
'console.log(JSON.stringify({ referencedTime, exact, wrong, resolved }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['referencedTime'] == '1783857900123'
|
||||
assert results['exact'] == '机器人出站正文'
|
||||
assert results['wrong'] == ''
|
||||
assert results['resolved']['reason'] == 'quoted_text_resolved'
|
||||
assert '机器人出站正文' in results['resolved']['prompt']
|
||||
|
||||
|
||||
def test_outbound_create_time_collision_fails_closed(tmp_path: Path) -> None:
|
||||
cache_path = tmp_path / 'hermes-message-text-cache.json'
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
f'process.env.AIOS_HERMES_WEIXIN_TEXT_MESSAGE_CACHE = {json.dumps(str(cache_path))};',
|
||||
'const createTimeMs = 1783857900123;',
|
||||
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第一条", createTimeMs);',
|
||||
'rememberHermesOutboundTextMessage("account-a", "sender-b", "第二条", createTimeMs);',
|
||||
'const resolved = resolveHermesCachedQuotedText("account-a", "sender-b", undefined, createTimeMs);',
|
||||
'console.log(JSON.stringify({ resolved }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['resolved'] == ''
|
||||
|
||||
|
||||
def test_missing_quoted_text_fails_closed_without_model_prompt(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const available = typeof resolveHermesQuotedTextContext === "function";',
|
||||
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } } }];',
|
||||
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
|
||||
'let modelCalls = 0;',
|
||||
'if (result && !result.handled) modelCalls += 1;',
|
||||
'console.log(JSON.stringify({ available, result, modelCalls }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['available'] is True
|
||||
assert results['result'] == {
|
||||
'referenced': True,
|
||||
'handled': True,
|
||||
'replyText': '无法读取被引用文字,请复制原文后再提问。',
|
||||
'reason': 'quoted_text_unavailable',
|
||||
}
|
||||
assert results['modelCalls'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hook_name', ['TS_PROCESS_HOOK', 'JS_PROCESS_HOOK'])
|
||||
def test_process_hook_stops_unavailable_quote_before_model_dispatch(hook_name: str) -> None:
|
||||
patcher = load_patcher_module()
|
||||
hook = getattr(patcher, hook_name)
|
||||
guard_name = 'TS_QUOTED_TEXT_DISPATCH_GUARD' if hook_name.startswith('TS_') else 'JS_QUOTED_TEXT_DISPATCH_GUARD'
|
||||
guard = getattr(patcher, guard_name)
|
||||
|
||||
assert 'rememberHermesTextMessage(deps.accountId, hermesSenderId, full, textBody)' in hook
|
||||
assert 'findHermesReferencedCreateTimeMs(full.item_list)' in hook
|
||||
assert 'resolveHermesCachedQuotedText(deps.accountId, hermesSenderId, hermesQuotedMessageId, hermesQuotedCreateTimeMs)' in hook
|
||||
assert 'resolveHermesQuotedTextContext(full.item_list, hermesContent, hermesCachedQuotedText)' in hook
|
||||
assert '!hermesQuotedTextContext.referenced && !textBody.startsWith("/")' in hook
|
||||
assert 'ctx.Body = hermesQuotedTextContext.prompt' in hook
|
||||
assert 'await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)' in guard
|
||||
assert 'markDispatchIdle();' in guard
|
||||
|
||||
|
||||
def test_dispatch_quoted_text_fail_closed_uses_dispatcher_without_model(tmp_path: Path) -> None:
|
||||
result = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'(async () => {',
|
||||
' const calls = [];',
|
||||
' const dispatcher = {',
|
||||
' sendFinalReply(payload) { calls.push(["final", payload]); },',
|
||||
' markComplete() { calls.push(["complete"]); },',
|
||||
' async waitForIdle() { calls.push(["idle"]); },',
|
||||
' };',
|
||||
' const context = resolveHermesQuotedTextContext([{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1" } } }], "这是什么意思?");',
|
||||
' let modelCalls = 0;',
|
||||
' const handled = await dispatchHermesQuotedTextFailClosed(context, dispatcher);',
|
||||
' if (!handled) modelCalls += 1;',
|
||||
' console.log(JSON.stringify({ handled, modelCalls, calls }));',
|
||||
'})();',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert result['handled'] is True
|
||||
assert result['modelCalls'] == 0
|
||||
assert result['calls'] == [
|
||||
['final', {'text': '无法读取被引用文字,请复制原文后再提问。'}],
|
||||
['complete'],
|
||||
['idle'],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('is_ts', 'guard_name', 'needle'),
|
||||
[
|
||||
(True, 'TS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
|
||||
(False, 'JS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
|
||||
],
|
||||
ids=['ts', 'js'],
|
||||
)
|
||||
def test_dispatch_guard_remove_and_reinsert_is_byte_idempotent(is_ts: bool, guard_name: str, needle: str) -> None:
|
||||
patcher = load_patcher_module()
|
||||
guard = getattr(patcher, guard_name)
|
||||
|
||||
def cycle(text: str) -> str:
|
||||
without = patcher.remove_existing_process_hermes_hook(text)
|
||||
return without.replace(needle, guard + '\n' + needle, 1)
|
||||
|
||||
once = cycle(needle)
|
||||
twice = cycle(once)
|
||||
|
||||
assert guard + '\n' + needle in once
|
||||
assert twice == once
|
||||
|
||||
|
||||
def test_reference_metadata_content_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
|
||||
result = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const items = [{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1", content: "metadata only" } } }];',
|
||||
'console.log(JSON.stringify(resolveHermesQuotedTextContext(items, "这是什么意思?")));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert result['handled'] is True
|
||||
assert result['reason'] == 'quoted_text_unavailable'
|
||||
assert result['replyText'] == '无法读取被引用文字,请复制原文后再提问。'
|
||||
|
||||
|
||||
def test_patch_plugin_process_only_skips_slash_and_channel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
patcher = load_patcher_module()
|
||||
calls: list[tuple[str, Path]] = []
|
||||
|
||||
def record(name: str, result):
|
||||
def _inner(root: Path):
|
||||
calls.append((name, root))
|
||||
return result
|
||||
|
||||
return _inner
|
||||
|
||||
monkeypatch.setattr(patcher, 'patch_ts', record('patch_ts', False))
|
||||
monkeypatch.setattr(patcher, 'patch_js', record('patch_js', False))
|
||||
monkeypatch.setattr(patcher, 'patch_process_messages', record('patch_process_messages', {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}))
|
||||
monkeypatch.setattr(patcher, 'patch_channel_files', record('patch_channel_files', {'src/channel.ts': False, 'dist/src/channel.js': False}))
|
||||
monkeypatch.setattr(patcher, 'verify_plugin_hook', record('verify_plugin_hook', {
|
||||
'hook_status': {},
|
||||
'hook_installed': False,
|
||||
'process_status': {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': True},
|
||||
'process_hook_installed': True,
|
||||
'channel_status': {},
|
||||
'channel_hook_installed': False,
|
||||
'dispatch_reference_found': False,
|
||||
'dispatch_reference_files': [],
|
||||
}))
|
||||
|
||||
result = patcher.patch_plugin(tmp_path, process_only=True)
|
||||
|
||||
assert calls == [
|
||||
('patch_process_messages', tmp_path),
|
||||
('verify_plugin_hook', tmp_path),
|
||||
]
|
||||
assert result['mode'] == 'process-only'
|
||||
assert result['plugin_root'] == str(tmp_path)
|
||||
assert result['slash_changes'] == {}
|
||||
assert result['channel_changes'] == {}
|
||||
assert result['process_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
|
||||
assert result['behavior_changes'] == {'src/messaging/process-message.ts': True, 'dist/src/messaging/process-message.js': False}
|
||||
assert result['verification']['process_hook_installed'] is True
|
||||
|
||||
|
||||
|
||||
def test_parse_args_supports_default_root_and_process_only_flag() -> None:
|
||||
patcher = load_patcher_module()
|
||||
|
||||
defaults = patcher.parse_args([])
|
||||
assert defaults.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
|
||||
assert defaults.process_only is False
|
||||
|
||||
process_only = patcher.parse_args(['--process-only'])
|
||||
assert process_only.plugin_root == patcher.DEFAULT_PLUGIN_ROOT
|
||||
assert process_only.process_only is True
|
||||
|
||||
custom_root = Path('/tmp/custom-plugin-root')
|
||||
custom = patcher.parse_args([str(custom_root), '--process-only'])
|
||||
assert custom.plugin_root == custom_root
|
||||
assert custom.process_only is True
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PATCHER_PATH = Path(__file__).resolve().parents[1] / 'fixes' / 'patch_openclaw_weixin_outbound_quotes.py'
|
||||
|
||||
|
||||
def load_patcher():
|
||||
spec = importlib.util.spec_from_file_location('patch_openclaw_weixin_outbound_quotes', PATCHER_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_send_patch_adds_exact_create_time_and_is_idempotent() -> None:
|
||||
patcher = load_patcher()
|
||||
ts = '''function buildTextMessageReq(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
contextToken?: string;
|
||||
runId?: string;
|
||||
clientId: string;
|
||||
}): SendMessageReq {
|
||||
const { to, text, contextToken, runId, clientId } = params;
|
||||
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
|
||||
}
|
||||
export async function sendMessageWeixin(params: {}): Promise<{ messageId: string }> {
|
||||
const { to, text, opts } = params;
|
||||
const clientId = generateClientId();
|
||||
const req = buildSendMessageReq({
|
||||
payload: {},
|
||||
clientId,
|
||||
});
|
||||
try {
|
||||
await sendMessageApi({ body: req });
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}
|
||||
|
||||
/** Send a single structured MessageItem downstream. */'''
|
||||
js = '''function buildTextMessageReq(params) {
|
||||
const { to, text, contextToken, runId, clientId } = params;
|
||||
return { msg: { client_id: clientId, message_type: MessageType.BOT } };
|
||||
}
|
||||
export async function sendMessageWeixin(params) {
|
||||
const clientId = generateClientId();
|
||||
const req = buildSendMessageReq({
|
||||
payload: {},
|
||||
clientId,
|
||||
});
|
||||
try {
|
||||
await sendMessageApi({ body: req });
|
||||
}
|
||||
catch (err) {
|
||||
throw err;
|
||||
}
|
||||
return { messageId: clientId };
|
||||
}
|
||||
/** Send a single structured MessageItem downstream. */'''
|
||||
|
||||
ts_once = patcher.patch_send_ts(ts)
|
||||
js_once = patcher.patch_send_js(js)
|
||||
assert patcher.patch_send_ts(ts_once) == ts_once
|
||||
assert patcher.patch_send_js(js_once) == js_once
|
||||
for result in (ts_once, js_once):
|
||||
assert 'const createTimeMs = Date.now();' in result
|
||||
assert 'text item missing' in result
|
||||
assert 'req.msg.item_list[0].create_time_ms = createTimeMs' in result
|
||||
assert 'req.msg.create_time_ms = createTimeMs' not in result
|
||||
assert 'return { messageId: clientId, createTimeMs }' in result
|
||||
Reference in New Issue
Block a user