Compare commits

...
Author SHA1 Message Date
Codex 95fc4d37f0 feat(codex): add reliable AIOS central orchestration gateway 2026-07-18 02:26:14 +08:00
Codex 9195470ceb chore(orchestration): dispatch adaptive OpenClaw and Codex work units 2026-07-17 22:13:38 +08:00
Codex cbe33ecab8 chore(governance): refresh adaptive dual-agent production contract 2026-07-17 22:13:38 +08:00
Codex e3fc48e5b9 chore(governance): refresh adaptive dual-agent production contract 2026-07-17 22:04:03 +08:00
Codex ed3bcf76c7 chore(governance): add shared V2R1 production execution contract 2026-07-17 21:24:20 +08:00
openclaw 3cf9958cb9 integrate(gate1): replay accepted clean-room evidence onto current main 2026-07-14 01:33:53 +08:00
openclaw 6b70a33080 fix(weixin): resolve outbound quotes by exact create time 2026-07-12 21:41:10 +08:00
openclaw 8d23692320 fix(weixin): preserve exact server message ids 2026-07-12 19:58:26 +08:00
openclaw 33d3947da6 fix(weixin): resolve quotes from exact message id cache 2026-07-12 17:09:33 +08:00
openclaw 806163b8ba docs(weixin): record visible quote parser release 2026-07-12 15:55:38 +08:00
openclaw 13c2a18286 fix(weixin): resolve visible quoted text summaries 2026-07-12 15:50:03 +08:00
openclaw b8f10951ea docs(weixin): record owner quoted text acceptance 2026-07-12 14:11:27 +08:00
openclaw 2fcf0879c6 docs(weixin): record quoted text fail-closed release 2026-07-12 13:46:14 +08:00
openclaw 1136b4186d fix(weixin): fail closed on unavailable quoted text 2026-07-12 13:42:32 +08:00
openclaw cca73ad52f docs(weixin): design quoted text fail-closed handling 2026-07-12 13:42:32 +08:00
openclaw 424bf36c03 Merge remote-tracking branch 'origin/audit' 2026-07-12 12:57:36 +08:00
Steel Dan (AIOS) 9eb00da0c4 docs(weixin): record mainline production release 2026-07-12 02:21:21 +08:00
Steel Dan (AIOS) 9269e81997 docs(weixin): record quoted text reference fix 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 6642820aa0 fix(weixin): ignore unconfirmed text references 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 8374c6b1c7 docs(weixin): update confirmed image reference recovery 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) c8a3251834 fix(weixin): require confirmed image reference context 2026-07-12 01:18:25 +08:00
Steel Dan (AIOS) 2418f56273 docs(weixin): record text cache isolation recovery 2026-07-12 01:18:11 +08:00
Steel Dan (AIOS) 297582941f fix(weixin): add process-only runtime patch mode 2026-07-12 01:18:11 +08:00
Steel Dan (AIOS) 958a61cbc4 fix(weixin): isolate text from stale image cache 2026-07-12 01:18:11 +08:00
V1R4C6 Deployment Audit 3096212919 audit: record V1R4C6 production deployment blocker 2026-07-12 00:57:05 +08:00
openclaw aa2b091966 start V1R4C6 production deployment and verification 2026-07-11 21:10:51 +08:00
gito 1a9cb9095e Merge pull request 'reconcile(v1r4c6): finalize baseline metadata — previous_baseline V1R4C5, git e204a90' (#9) from reconcile/v1r4c6-baseline-finalize-20260711 into main 2026-07-11 20:55:54 +08:00
openclaw 0312159168 feat(reconcile): write V1R4C6 baseline metadata — previous_baseline V1R4C5, git e204a90 2026-07-11 20:54:46 +08:00
openclaw e204a90398 docs: baseline V1R4C6 promotion 2026-07-11 20:47:44 +08:00
openclaw d8cf6058e9 docs: promote VERSION V1R4C3 -> V1R4C6 ahead of main promotion 2026-07-11 20:47:28 +08:00
openclaw 2aefc19e3d authorize OpenClaw to execute V1R4C6 merge tests deploy pipeline 2026-07-11 20:41:31 +08:00
gito 28a430066f Merge pull request 'V1R4C6: merge candidate → main (release control artifacts)' (#8) from candidate/v1r4c6-integration-main-20260711 into main 2026-07-11 19:30:58 +08:00
openclaw 1013aef461 Merge branch 'release/v1r4c6-admin-search-agent-integration-20260711' into candidate/v1r4c6-integration-main-20260711
Resolve merge conflicts per V1R4C6 release control merge:

- baseline/current.json: keep feature branch version (updates to V1R4C5,
  adds constraints block). This was the intended runtime state that main
  was missing after V1R4C5 backfill (PR #7). Feature branch correctly has
  the V1R4C5 state; main still had V1R4C3.

- versions/V1R4C5.json: keep main version (complete fields with codename,
  tag_ref, tag_object, constraints, promoted_at/by). Feature branch had an
  older copy lacking these fields.

- docs/releases/V1R4C6-acceptance-evidence.md: keep main version (blank
  'NOT RUN' status). Feature branch had draft content.

- docs/releases/V1R4C6-release-gate-status.md: keep main version (blank
  'NOT RUN' status). Feature branch had draft content.
2026-07-11 19:29:47 +08:00
gito bb3210d74f Merge pull request 'docs: update V1R4C6 metadata to NOT RUN after control artifacts merge' (#7) from reconcile/v1r4c6-gate-status-notrun-docs-20260711 into main 2026-07-11 17:07:03 +08:00
openclaw 745395e93b docs: update V1R4C6 gate status, acceptance checklist, and acceptance evidence to NOT RUN
All engineering gates remain pending. This updates the release documents
to reflect the current integration-pending status after PR #6 merged.
2026-07-11 17:06:31 +08:00
gito ae49bf0fa3 Merge pull request 'reconcile: merge V1R4C6 release candidate into main' (#6) from candidate/v1r4c6-merge-to-main-20260711 into main 2026-07-11 16:45:28 +08:00
openclaw 8410cd895e reconcile: merge V1R4C6 release candidate into main
V1R4C6 merge candidate (squashed).
Contains 10 files, ~1440 lines added across:
- docs/releases/ (6 files: checklist, evidence, blocker, resume, merge-matrix, gate-status)
- ops/openclaw/ (2 files: integrate-deploy-verify, run-to-owner-gate-wechat)
- patches/ (1 file: branch patch)
- versions/ (1 file: V1R4C6.json)

Task: admin workbench, SR-03 Search Gateway, Agent Platform Integration
Baseline: V1R4C5
V1R4C5 tag semantics:
- tag refs/tags/V1R4C5 target d457ac3: immutable release snapshot
- baseline git a32c76a: runtime baseline promotion commit
No tag creation, baseline promotion, or memory search activation in this merge.
2026-07-11 16:34:03 +08:00
gito dc1642c2e8 Merge pull request 'reconcile: V1R4C5 baseline metadata (current.json + V1R4C5.json)' (#5) from reconcile/v1r4c5-baseline-metadata-20260711 into main 2026-07-11 13:48:30 +08:00
openclaw c75b865710 reconcile: update baseline registry to V1R4C5 and create V1R4C5 version metadata
Root cause:
The V1R4C5 baseline promotion commit (a32c76a) was made on a feature branch
that was never merged to main. As a result, both baseline/current.json and
versions/V1R4C5.json on main still reflect V1R4C3 state. Runtime
getBaseline() continues to return V1R4C3 with commit f5435e3.

Changes:
- baseline/current.json: update current_baseline -> V1R4C5, git ->
  a32c76a3c1 (the actual promotion commit),
  add constraints from the original promotion
- versions/V1R4C5.json: create the missing version metadata file with status
  active-baseline, referencing V1R4C5 tag (object ac9bdac...)

Why this does not affect V1R4C5 release content:
- V1R4C5 release documents (docs/releases/*.md) are untouched
- V1R4C5 tag is unchanged and not moved
- No V1R4C6 functionality introduced
- No memory search or vector index enabled

Rollback method:
  git revert <this commit hash>
  # Or restore baseline/current.json from V1R4C3 content:
  # git checkout main~1 baseline/current.json
  # And delete versions/V1R4C5.json:
  # git rm versions/V1R4C5.json
  # Then commit the revert

Constraints honored:
  - No direct writes to main (using PR)
  - No force push
  - V1R4C5 tag not moved/rebuild
  - No V1R4C6 content
  - Memory search remains disabled
2026-07-11 13:46:13 +08:00
gito e992b624e0 V1R4C5 → main: backfill release docs (acceptance evidence + owner approval) 2026-07-11 13:37:58 +08:00
openclaw c60e4cd02c reconcile: backfill V1R4C5 release documents (acceptance evidence + final owner approval) for main merge
Source: V1R4C5 tag based on unique publication commit a32c76a
- docs/releases/V1R4C5-acceptance-evidence.md (from V1R4C5 tag)
- docs/releases/V1R4C5-final-owner-approval.md (from V1R4C5 tag)

Constraints honored:
- Not writing directly to main
- No V1R4C6 content introduced
- Memory search remains disabled per owner approval

Generated merge candidate branch: reconcile/v1r4c5-release-to-main-20260711
2026-07-11 13:36:18 +08:00
openclaw 4b2996ca9f release: resume V1R4C6 after scoped WeChat notification verification 2026-07-11 07:47:05 +08:00
openclaw 086076b74f ops: direct OpenClaw to complete V1R4C6 and request owner approval via WeChat 2026-07-11 03:13:32 +08:00
openclaw 7b2d694272 ops: queue actual V1R4C6 integration deployment and verification 2026-07-11 03:10:28 +08:00
openclaw 0583060c7c release: update V1R4C6 gate status 2026-07-11 03:07:13 +08:00
openclaw 20e707fb52 release: add V1R4C6 acceptance evidence 2026-07-11 03:05:52 +08:00
openclaw 6c75ec6bb2 release: add V1R4C6 acceptance checklist 2026-07-11 03:04:02 +08:00
openclaw e9fefd4c18 release: add V1R4C6 integration merge matrix 2026-07-11 03:01:59 +08:00
openclaw 3ece481c74 fix(runtime): point GitOps API to active aios.git bare repository 2026-07-11 02:56:49 +08:00
openclaw 33177548c3 fix(baseline): stabilize V1R4C5 git pointer 2026-07-11 02:55:05 +08:00
openclaw ed1413a05e V1R4C5 baseline promotion: V1R4C3 -> V1R4C5
promoted_by: openclaw

promotion_reason: V1R4C5 SR-01 Gitea branch sync fix and SR-02 Watcher state cleanup

memory_search: disabled
2026-07-11 02:46:00 +08:00
openclaw 05d1e64228 release: record V1R4C6 baseline visibility blocker 2026-07-11 02:46:00 +08:00
openclaw 64bbe1b51c Create V1R4C6 integration release patch 2026-07-11 02:46:00 +08:00
openclaw 3c6f2c8c65 release: start V1R4C6 admin search agent integration 2026-07-11 02:26:31 +08:00
72 changed files with 11241 additions and 8 deletions
@@ -0,0 +1,32 @@
{
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"created_at": "2026-07-17T14:13:38+00:00",
"schema_version": 1,
"state_writer": "openclaw",
"work_units": [
{
"fallback": "codex",
"gate": "R0_RUNNER_LIVE_RECOVERY",
"id": "R0-DISCOVER-SECRET-PROVIDER",
"primary": "openclaw",
"status": "READY",
"track": "R_RUNNER"
},
{
"fallback": "codex",
"gate": "P2_REAL_B3_EVIDENCE",
"id": "P2-INVENTORY-REAL-EVIDENCE",
"primary": "openclaw",
"status": "READY",
"track": "P_PERSONAL_WEIXIN"
},
{
"fallback": "openclaw",
"gate": "P1_REPAIR3_CODE",
"id": "P1-POST-CONTRACT-REACK",
"primary": "codex",
"status": "READY",
"track": "P_PERSONAL_WEIXIN"
}
]
}
@@ -0,0 +1,157 @@
{
"adaptive_dispatch": {
"codex_first": [
"complex_code",
"security_boundary",
"architecture",
"high_risk_review",
"strong_model_materially_better"
],
"default_orchestrator": "openclaw",
"dual_required": [
"release_gate",
"crypto_or_identity_change",
"production_migration_or_rollback"
],
"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,
"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"
}
},
"contract_id": "V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT",
"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-17T14:13:38+00:00",
"handoff": {
"ack": "CONTRACT_ACK=<full_contract_commit_oid>",
"advance": "independent_verification_required",
"all_agents_match": true
},
"join_gate": {
"requires": [
"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": [
"MAIN_MERGE",
"STAGING_AND_CANARY",
"PRODUCTION"
],
"schema_version": 3,
"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"
],
"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"
]
}
}
}
@@ -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,15 @@
{
"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,
"production_release": "NOT_STARTED",
"release": "paused",
"repair3": "PASS_CANDIDATE_PREPARED",
"runner": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
"schema_version": 1,
"single_state_writer": "openclaw",
"updated_at": "2026-07-17T14:13:38+00:00"
}
@@ -0,0 +1,69 @@
# Codex task: apply and verify AIOS Orchestrator Gateway v1
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
Use the strongest actually available Codex model for the initial security review.
If unavailable or timed out, record TIMEOUT/NOT_EXECUTED and let OpenClaw run
the deterministic test path. Do not count a timeout as PASS.
## Apply
1. Create a new clean append-only branch and isolated worktree.
2. Validate the delivery tarball and `SHA256SUMS`.
3. Run the external `scripts/apply_gateway_patch.sh` with `SOURCE_DIR` and
`WORKTREE`; do not alter unrelated paths.
4. Review all added code, especially:
- exact contract and optimistic state-version checks;
- event/decision idempotency;
- SQLite leases and retry behavior;
- Workspace Agent `202` asynchronous callback flow;
- Responses background creation and polling;
- file-inbox and Git-ref polling;
- traversal/symlink protections;
- OpenClaw lifecycle synchronization;
- systemd sandbox;
- install and rollback transactions.
## Tests
Run from the final source tree:
- Python compile for all gateway and helper files;
- unit tests;
- JSON schema/config parsing;
- Bash syntax;
- `systemd-analyze verify`;
- event duplicate and payload-conflict tests;
- stale state-version rejection;
- lease/double-claim test;
- Workspace trigger failure and Responses fallback;
- response queued/in-progress/completed polling;
- file-inbox recovery after HTTP failure;
- Git-ref initial baseline and subsequent-change event;
- task start/end and project summary synchronization;
- dispatch traversal and symlink rejection;
- retry/backoff/dead-letter;
- install dry-run;
- rollback test in an isolated fixture;
- `git diff --check`;
- full repository tests;
- final Gitleaks.
## Commit and review
1. Create an append-only implementation commit.
2. Push without force.
3. Confirm `WORKTREE_OID == BARE_OID == GITEA_OID`.
4. Create an independent RESULT-only branch and exact-one result commit.
5. Record the source bundle SHA-256, implementation OID, RESULT OID, tests,
Gitleaks, systemd validation and remaining external configuration blockers.
## Forbidden
No reset, amend, rebase, force push, direct main push, controller mutation,
secret disclosure, fabricated test result, production deployment or B3 evidence
fabrication.
Success:
`GATEWAY_CODE_REVIEW=PASS IMPLEMENTATION=<oid> RESULT=<oid> OID_MATCH=YES`
@@ -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.
@@ -0,0 +1,68 @@
# OpenClaw task: deploy and supervise AIOS Orchestrator Gateway v1
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
You are the default orchestrator and deployment supervisor.
## Preconditions
- Codex has applied and independently reviewed this source bundle on a clean,
append-only branch.
- Implementation and RESULT OIDs are identical across worktree, bare repo and
Gitea.
- Final Gitleaks and repository tests are PASS.
- No force push, reset, amend or rebase occurred.
## Responsibilities
1. Validate the source bundle SHA-256 and `SHA256SUMS`.
2. Review the generated systemd service, poll timer, tmpfiles and sysusers files.
3. Install through `scripts/install_aios_orchestrator_gateway.sh`.
4. Configure one or both central-model channels:
- published Workspace Agent API channel with `agtch_...` trigger ID and a
Workspace Agent access token stored outside Git; or
- Responses API fallback with an approved model and API key stored outside Git.
5. Publish the callback endpoint securely and configure the Workspace Agent
action from `workspace-agent/orchestrator-decision-action.openapi.yaml`.
6. Confirm that the Workspace Agent instructions match
`workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md`.
7. Enable both:
- the continuously running gateway service;
- the independent `aios-orchestrator-gateway-poll.timer`.
8. Verify HTTP event ingestion, file-inbox polling, Git-ref polling,
Workspace Agent trigger acceptance, callback decision, Responses background
polling fallback, dispatch queue generation, retry leases and loop guards.
9. Verify OpenClaw receives and synchronizes:
- TASK_DISPATCHED
- TASK_STARTED
- TASK_ENDED
- PROJECT_SUMMARY
- PROJECT_COMPLETED
10. Run a harmless bootstrap work unit through OpenClaw and Codex and confirm
duplicate event submission is idempotent.
11. Run rollback, reinstall and heartbeat/health verification.
12. Create one RESULT-only review recording exact commits, artifact digest,
tests, systemd security, polling recovery, lifecycle synchronization and
rollback.
## Fail-closed boundaries
- Do not put Workspace Agent tokens, API keys, callback tokens or gateway
credentials in Git, logs, command arguments or RESULT files.
- Do not claim the gateway resolves the existing B3 credential/raw-wire blocker.
- Do not advance B4 or production merely because this gateway is healthy.
- Do not bypass owner/admin production release approvals.
- Do not use reset, amend, rebase, force push, direct main push or bare-ref edits.
## Required final status
Success:
`ORCHESTRATOR_GATEWAY_LIVE=PASS POLLING=PASS OPENCLAW_SYNC=PASS ROLLBACK=PASS`
Blocker:
`ORCHESTRATOR_GATEWAY_LIVE=BLOCKED reason=<stable_reason>`
Keep `BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED` until the
existing production contract advances all release gates.
@@ -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.
+107
View File
@@ -0,0 +1,107 @@
# AIOS Orchestrator Gateway v0.2
This component turns the current passive GPT orchestration pattern into a
durable, unattended loop:
```text
Gitea/OpenClaw/Codex event
-> HTTP event endpoint or polled file inbox
-> durable SQLite state machine
-> Workspace Agent API trigger
-> strict callback decision
-> Responses API background fallback and polling
-> OpenClaw/Codex dispatch queues
-> start/end/result events
-> next orchestration cycle
```
## Reliability mechanisms
The gateway does not depend on one timer, webhook or model call:
- HTTP event ingestion.
- Periodic file-inbox polling for missed webhooks and offline producers.
- Local bare-Git ref polling for `main`, governance, patch and result refs.
- Workspace Agent retries with a stable event `Idempotency-Key`.
- Workspace Agent callback timeout and Responses API background fallback.
- Responses status polling until completion or fail-closed terminal status.
- SQLite optimistic state versions, idempotency records and processing leases.
- Exponential retry with a maximum retry count and dead-letter state.
- Same-blocker and maximum-hop guards to stop non-progress loops.
- A systemd timer runs `--poll-once` as an independent reliability watchdog.
Workspace Agent trigger runs are asynchronous and do not expose a retrievable
final response. The central Workspace Agent must therefore call the
`submit_orchestration_decision` action.
## OpenClaw synchronization
Every lifecycle stage is durably written to the OpenClaw synchronization queue:
- `TASK_DISPATCHED`
- `TASK_STARTED`
- `TASK_ENDED`
- `DECISION_APPLIED`
- `PROJECT_SUMMARY`
- `PROJECT_COMPLETED`
An optional authenticated OpenClaw HTTP endpoint can receive the same records.
A failed HTTP delivery remains in the lifecycle retry queue. OpenClaw remains
the default project-status synchronizer and authoritative progress presenter.
## Agent execution contract
Each dispatched JSON envelope contains:
- exact contract commit;
- source event;
- clean base commit;
- primary and fallback agent;
- allowed paths;
- required checks;
- forbidden operations;
- completion event callback format.
OpenClaw and Codex must send a `work_unit_started` event before execution and a
`work_unit_completed`, `work_unit_blocked`, or `work_unit_failed` event when
they stop. This guarantees that OpenClaw can synchronize every task start,
finish, and overall project summary.
## Security
- Model and callback credentials are read only from root-owned secret files.
- Credentials never enter Git, dispatch payloads or logs.
- The gateway runs as a dedicated unprivileged user.
- Dispatch and sync paths are protected against traversal and symlink targets.
- All state changes require the exact contract commit and current state version.
- Production evidence, credentials and approvals stay fail-closed.
- Git history operations remain controlled by the committed governance contract.
## Deployment sequence
1. Apply this source bundle to a clean append-only branch.
2. Run unit, repository, shell, JSON, systemd and Gitleaks checks.
3. Configure one or both model channels:
- published Workspace Agent API channel and Workspace Agent access token;
- Responses API key and approved fallback model.
4. Publish the decision callback as a Workspace Agent action or MCP tool.
5. Install the service through `scripts/install_aios_orchestrator_gateway.sh`.
6. Run `scripts/verify_aios_orchestrator_gateway.sh`.
7. Submit a harmless event and verify:
- trigger acceptance;
- strict decision callback;
- dispatch file;
- OpenClaw task-start/task-end/project-summary synchronization;
- duplicate idempotency;
- polling recovery after suppressing the HTTP webhook.
The installer copies an explicit gateway-only allowlist into a release directory
named by its SHA-256 digest, atomically updates `/opt/aios-orchestrator-gateway/current`,
and persists `/var/lib/aios-orchestrator-gateway/install-receipt.json` with the
current and previous release. `--dry-run` performs source validation and emits
`AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS` without changing installed state.
Rollback reads that root-owned `0600` receipt, verifies both release directory
names and `SOURCE_DIGEST` files, coordinates the gateway and poll units, and
atomically switches `current` to the previous release. Repeating the same
rollback is a no-op and emits `NOOP_ALREADY_ROLLED_BACK`.
+1 -1
View File
@@ -1 +1 @@
V1R4C3
V1R4C6
+12 -7
View File
@@ -1,8 +1,13 @@
{
"current_baseline": "V1R4C3",
"previous_baseline": "V1R3C1",
"promoted_at": "2026-07-09T17:44:57+08:00",
"git": "f5435e308794c737f3e39de3f6b79807486943c3",
"promoted_by": "openclaw",
"promotion_reason": "V1R4C3 autonomous operations, search gateway, task center, owner gate, and blocker self-optimization baseline promotion"
}
"current_baseline": "V1R4C6",
"previous_baseline": "V1R4C5",
"promoted_at": "2026-07-11T21:00:00+08:00",
"git": "e204a90398bf646d46245b3088226749fa056edd",
"promoted_by": "openclaw/admin",
"promotion_reason": "V1R4C6 admin search agent integration, search gateway, agent platform, VERSION bump, merge tests deploy pipeline — not yet deployed to production",
"constraints": {
"memory_search_enabled": false,
"force_push_allowed": false,
"gitea_db_mutation_requires_owner_approval": true
}
}
@@ -0,0 +1,49 @@
{
"host": "127.0.0.1",
"port": 18920,
"database_path": "/var/lib/aios-orchestrator-gateway/state.sqlite3",
"event_inbox_dir": "/var/lib/aios-orchestrator-gateway/inbox/events",
"decision_inbox_dir": "/var/lib/aios-orchestrator-gateway/inbox/decisions",
"dispatch_outbox_dir": "/var/lib/aios-orchestrator-gateway/outbox",
"openclaw_sync_dir": "/var/lib/aios-orchestrator-gateway/openclaw-sync",
"command_queue_dir": "/var/lib/aios-orchestrator-gateway/commands",
"inbound_token_file": "/etc/aios-orchestrator-gateway/inbound.token",
"callback_token_file": "/etc/aios-orchestrator-gateway/callback.token",
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
"workspace_agent": {
"trigger_id": "agtch_REPLACE_ME",
"access_token_file": "/etc/aios-orchestrator-gateway/workspace-agent.token"
},
"responses_fallback": {
"api_key_file": "/etc/aios-orchestrator-gateway/openai-api.key",
"model": "REPLACE_WITH_APPROVED_MODEL"
},
"polling": {
"interval_seconds": 10,
"summary_interval_seconds": 300
},
"loop_controls": {
"callback_timeout_seconds": 900,
"lease_seconds": 60,
"retry_base_seconds": 10,
"max_retries": 5,
"max_same_blocker_retries": 3,
"max_hops_per_gate": 20
},
"openclaw_sync": {
"endpoint": null,
"token_file": null
},
"project_key": "v2r1-personal-weixin-production",
"git_poll": {
"enabled": true,
"bare_repo": "/data/aios-gitops/repo.git",
"ref_prefixes": [
"refs/heads/main",
"refs/heads/task/governance/",
"refs/heads/patch/",
"refs/heads/result/"
],
"interval_seconds": 30
}
}
+7
View File
@@ -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.
+14
View File
@@ -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
+15
View File
@@ -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
+7
View File
@@ -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.
+11
View File
@@ -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,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。
+212
View File
@@ -0,0 +1,212 @@
# V1R4C5 Acceptance Evidence
Date: 2026-07-11
Status: release candidate evidence in progress
Current active baseline: V1R4C3
V1R4C5 has not been promoted yet.
## Owner Decision
Owner approved continued V1R4C5 work under the following constraints:
```text
允许无 force-push 的分支 reconcile
memory search 保持关闭
先完成 SR-01/SR-02/数据隔离验收
再进入 merge/deploy/tag owner gate
```
## Scope
V1R4C5 release candidate scope:
1. SR-01 Gitea reliability and branch consistency guardrails.
2. SR-02 watcher/stuck command cleanup.
3. User data isolation redline and enforcement gates.
4. SR-04 admin workbench read-only visibility preparation only.
Explicitly excluded from V1R4C5 activation:
- memory search activation;
- full SR-04 portal production rollout;
- bulk merge of old test/smoke/auth branches;
- force push reconciliation;
- Gitea DB mutation without owner/admin approval.
## Runtime Acceptance Evidence
### Runtime status
Observed:
```json
{
"service": "aios-gitops-model-api",
"status": "active",
"workspace_branch": "patch/v1r4c5-runtime-acceptance-clean-20260711-01"
}
```
### Baseline
Observed:
```json
{
"current_baseline": "V1R4C3",
"previous_baseline": "V1R3C1",
"promoted_by": "openclaw"
}
```
### Clean createBranch acceptance
Branch:
```text
test/v1r4c5-runtime-acceptance-clean-20260711-01
```
Result:
```text
status: ok
commit: 6ae7058
sync: synced
```
### Clean createPatch acceptance
Patch:
```text
patch/v1r4c5-runtime-acceptance-clean-20260711-01
```
Patch id:
```text
patch-1783706123789-f5ca8b69
```
Commit:
```text
ba0391e
```
Result:
```text
synced
```
## SR-01 Evidence
Known accepted evidence:
- Gitea branch consistency sentinel deployed.
- Sentinel reports:
```json
{
"status": "healthy",
"unhealthy_checks": []
}
```
- systemd timer runs every 5 minutes.
- service runs with:
```text
--emit-safe-mode --json
```
- broken symlink issue resolved by replacing the external reliability symlink with workspace-local files.
Required before final promotion:
- confirm latest sentinel report remains healthy after acceptance writes.
## SR-02 Evidence
Known accepted evidence:
```text
11 / 11 tests pass
```
Fix summary:
- stuck command detection reads parsed timestamps through `data.get('ts', 0)`;
- `.md` fixture mtime is aligned with stored stuck timestamp via `os.utime(path, (ts, ts))`;
- behavior is consistent across `.json` and `.md` state formats.
Required before final promotion:
- re-run SR-02 tests on the final integration candidate.
## User Data Isolation Evidence
Implemented controls:
- user data isolation redline;
- memory scope guard;
- provider gate;
- activation gate;
- audit service/timer;
- CI acceptance scripts;
- memory search activation check.
Required safety state:
```text
AIOS_MEMORY_ENABLED=false
```
Required before memory/search activation:
- isolation audit healthy;
- no denied records;
- no invalid records;
- provider filters by scope before ranking;
- provenance required;
- global index disabled;
- activation gate returns allowed;
- explicit owner approval.
V1R4C5 does not approve memory search activation.
## SR-04 Admin Workbench Evidence
Current user-visible gap is confirmed:
- administrator cannot yet see task progress;
- administrator cannot yet see flow/process diagrams;
- SR-04 remains activation-ready but gated.
V1R4C5 scope allows only read-only visibility preparation and acceptance design, not full production rollout.
Required before SR-04 deployment:
- SR-01 green;
- SR-02 green;
- user data isolation gate accepted;
- admin RBAC read-only check green.
## Remaining Required Gates Before Promotion
- [ ] Latest Gitea sentinel report remains healthy.
- [ ] SR-02 watcher tests pass on final candidate.
- [ ] User data isolation acceptance passes.
- [ ] Admin workbench read-only RBAC acceptance passes or remains explicitly deferred.
- [ ] No force-push was used.
- [ ] Memory search remains disabled.
- [ ] Owner approves merge/deploy/tag/promotion.
## Promotion Rule
V1R4C5 may not be promoted until all required gates are green or explicitly deferred with owner approval.
@@ -0,0 +1,54 @@
# V1R4C5 Final Owner Approval
Date: 2026-07-11
Status: approved for final promotion
## Owner Decision
Owner approved final V1R4C5 promotion.
Memory search must remain disabled.
```text
允许最终晋升
继续保持 memory search 关闭
```
## Promotion Scope
Approved for V1R4C5:
1. SR-01 Gitea reliability and branch consistency guardrails.
2. SR-02 watcher/stuck command cleanup.
3. User data isolation redline and enforcement gates.
4. SR-04 admin workbench read-only visibility preparation only.
## Explicit Non-Activation
The following are not approved by this promotion:
```text
memory search activation
full cross-session memory recall
global vector index
full SR-04 production portal rollout beyond read-only/admin visibility preparation
bulk merge of old test/smoke/auth branches
force-push based reconciliation
```
## Required Runtime State
Memory remains disabled:
```text
AIOS_MEMORY_ENABLED=false
```
If a provider or deployment attempts to enable memory/search, it must be blocked unless a future owner approval explicitly allows it after the user-data isolation activation gate returns allowed.
## Final Promotion Rule
V1R4C5 may proceed to tag/promotion under owner gate.
If any final gate fails, promotion must stop and memory/search must remain disabled.
+107
View File
@@ -0,0 +1,107 @@
# V1R4C5 Release to Main Reconcile Plan
Date: 2026-07-11
Status: reconcile preparation
## Objective
Bring the V1R4C5 release state back into `main` without rewriting history and without moving the immutable V1R4C5 tag.
## Source of Truth
Release baseline:
```text
V1R4C5
```
Promotion commit:
```text
a32c76a3c1baa49562e53fb8adce10a5ed0e6704
```
Tag:
```text
V1R4C5
```
Tag policy:
```text
immutable
```
## Reconcile Rules
Allowed:
- normal merge commits;
- fast-forward where possible;
- conflict resolution with review;
- updating main to contain released C5 state.
Forbidden:
- force-push;
- tag movement;
- rewriting release history;
- importing V1R4C6 feature code into this reconcile.
## Expected Included C5 Content
- SR-01 Gitea reliability guardrails.
- Gitea branch consistency sentinel.
- SR-02 watcher state cleanup.
- User Data Isolation gate.
- Reliability governance documents.
- Admin Workbench read-only preparation records.
## Expected Excluded Content
Do not include:
- V1R4C6 Admin Workbench implementation code.
- SR-03 Search Gateway implementation.
- Agent Platform integration.
- Memory search activation.
- Global vector index.
## Validation Before Main Merge
Required:
```text
getBaseline.current_baseline == V1R4C5
V1R4C5 tag unchanged
memory search disabled
Gitea sentinel healthy
SR-02 tests pass
main merge does not include V1R4C6 branches
```
## Post Merge Validation
Run:
```text
getRuntimeStatus
getBaseline
createBranch
createPatch
```
Expected:
```text
main contains V1R4C5 release state
active baseline remains V1R4C5
```
## Owner Gate
Normal merge to `main` may proceed after validation.
No tag creation or baseline promotion is required because V1R4C5 is already promoted.
@@ -0,0 +1,137 @@
# V1R4C6 Acceptance Checklist
Date: 2026-07-11
Base baseline: V1R4C5
Promotion blocked until every mandatory gate is green.
## Global Preflight
- [ ] `getRuntimeStatus.status == active`
- [ ] `getBaseline.current_baseline == V1R4C5`
- [ ] `getBaseline.git == a32c76a3c1baa49562e53fb8adce10a5ed0e6704`
- [ ] `AIOS_GITOPS_BARE_REPO` points to `/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git`
- [ ] Gitea consistency sentinel reports `healthy`
- [ ] `AIOS_MEMORY_ENABLED=false`
- [ ] no force-push used
## Gate A — Admin Workbench / Portal AIOS
### Route/API
- [ ] `/portal/me/aios/manifest` returns authenticated module manifest
- [ ] `/portal/me/aios/dashboard` returns admin-authorized dashboard data
- [ ] `/portal/me/aios/health` returns system health data
- [ ] failed/empty backend data produces explicit empty/error state
### UI
- [ ] admin sees task progress
- [ ] admin sees process/flow diagram entry
- [ ] admin sees system health
- [ ] admin sees authorized agent observability
- [ ] user does not see admin-only modules
- [ ] admin does not see owner-only modules without owner role
### Isolation
- [ ] no cross-user data
- [ ] no cross-project data
- [ ] no cross-session data
- [ ] no cross-tenant/group data
Gate A result: `PASS / FAIL / DEFER`
## Gate B — SR-03 Search Gateway
### Provider Binding
- [ ] real provider configured and reachable
- [ ] provider errors return safe non-memory fallback
- [ ] timeout/retry/circuit-breaker behavior verified
### Scope and Provenance
- [ ] scope filter occurs before ranking
- [ ] provenance returned for accepted results
- [ ] denied/out-of-scope content never reaches ranking or summary
- [ ] global memory/vector index disabled
- [ ] memory search remains disabled
### Audit
- [ ] search request scope is logged without leaking content
- [ ] provider/fallback decision is auditable
- [ ] denied scope mismatch is auditable
Gate B result: `PASS / FAIL / DEFER`
## Gate C — Agent Platform
### Event Bus
- [ ] event envelope includes tenant/group, user, project, session and agent scope
- [ ] cross-scope subscribers cannot receive events
- [ ] failed delivery is observable and retry-bounded
### GPT Message Plane
- [ ] message envelope includes scope and provenance
- [ ] messages cannot cross user/project/session boundaries
- [ ] denied messages do not forward content
### Controller Dispatch
- [ ] dispatch stops when autonomous safe mode is active
- [ ] consequential actions require owner gate
- [ ] duplicate dispatch is idempotent or rejected
- [ ] stuck state cleanup remains green
### Observability
- [ ] admin can see platform status without unrestricted content access
- [ ] failures and queue state are visible
Gate C result: `PASS / FAIL / DEFER`
## Combined Runtime Acceptance
Run after integration and deployment:
```text
getRuntimeStatus
getBaseline
createBranch
createPatch
```
Expected before promotion:
```text
runtime = active
baseline = V1R4C5
write branch = committed + synced
write patch = committed + synced
```
## Rollback Validation
- [ ] Group A can disable workbench routes/menu entries
- [ ] Group B can disable provider binding and use safe non-memory fallback
- [ ] Group C can disable expanded dispatch
- [ ] all rollback modes preserve `AIOS_MEMORY_ENABLED=false`
## Release Decision
V1R4C6 is tag-ready only when:
```text
Global preflight PASS
AND Gate A PASS
AND Gate B PASS
AND Gate C PASS
AND combined runtime acceptance PASS
```
Any failed mandatory gate blocks tag, deployment promotion, and baseline promotion.
@@ -0,0 +1,51 @@
# V1R4C6 Acceptance Evidence
Date: 2026-07-11
## Merge
- **PR #6** (merge commit `ae49bf0fa35b0f599a96a4d12644576447f39cec`): V1R4C6 release-candidate control artifacts merged to main.
- **Result**: PASS
- 10 files (1440 lines) added: release docs, patch records, ops commands, version metadata
- No V1R4C6 tag created
- No baseline promotion
- Memory search remains disabled
- Force-push remains disabled
## Verified State (post-merge)
| Check | Result |
|--------------------------|--------|
| main HEAD | ae49bf0fa35b0f599a96a4d12644576447f39cec |
| V1R4C5 tag | Still at d457ac396147043ed5d0a3fd8b44bcb10f48bceb (not moved) |
| V1R4C6 tag | absent |
| baseline/current.json | current_baseline = V1R4C5, git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704 |
| memory_search_enabled | false |
| force_push_allowed | false |
| versions/V1R4C5.json | exists |
| versions/V1R4C6.json | exists |
## Gates
| Gate | Status | Notes |
|------------------------------|---------|--------------------------------|
| control artifacts merge | PASS | PR #6 merged to main |
| Gate A — Admin Workbench | NOT RUN | Engineering integration pending|
| Gate B — SR-03 Search Gateway| NOT RUN | Engineering integration pending|
| Gate C — Agent Platform | NOT RUN | Engineering integration pending|
| tests | NOT RUN | No test suite executed |
| artifact digest | NOT CREATED | No build performed |
| deployment | NOT RUN | No deploy performed |
| production verification | NOT RUN | No production verification |
## Rollback
Revert command (creates new branch and PR, non-destructive):
```bash
git revert -m 1 ae49bf0fa35b0f599a96a4d12644576447f39cec
```
## Current Overall Status
**BLOCKED / INTEGRATION PENDING** — all engineering gates remain unexecuted.
@@ -0,0 +1,80 @@
# V1R4C6 Blocker: Baseline Visibility Discrepancy
Date: 2026-07-11
Status: blocking merge/deploy/tag
## Summary
OpenClaw/operator reported V1R4C5 baseline promotion completed successfully, but the ChatGPT GitOps action view still reports the active baseline as V1R4C3.
V1R4C6 must not proceed to merge/deploy/tag until this view is reconciled.
## Operator Reported State
Reported by operator:
```text
current_baseline = V1R4C5
previous_baseline = V1R4C3
promotion_git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704
promoted_by = openclaw/admin
memory_search_enabled = false
force_push_allowed = false
gitea_db_mutation_requires_owner_approval = true
```
## ChatGPT GitOps Action Observed State
Observed through `getBaseline`:
```text
current_baseline = V1R4C3
previous_baseline = V1R3C1
git = f5435e308794c737f3e39de3f6b79807486943c3
promoted_by = openclaw
```
Observed through `listVersions`:
```text
V1R4C5 tag exists
versions_dir does not list canonical V1R4C5.json
workspace_branch = release/v1r4c6-admin-search-agent-integration-20260711
```
## Risk
If V1R4C6 proceeds while different controllers disagree on the active baseline, the release may be built against the wrong base and post-deployment verification may be invalid.
## Required Reconciliation
Before continuing V1R4C6:
1. Confirm the GitOps API used by ChatGPT reads the same `baseline/current.json` updated by OpenClaw.
2. Confirm the API service has reloaded or refreshed baseline state.
3. Confirm canonical `versions/V1R4C5.json` is visible to the action runtime or intentionally explain why it is not required.
4. Re-run:
```text
getBaseline
listVersions
getRuntimeStatus
```
Required result:
```text
getBaseline.current_baseline = V1R4C5
listVersions.tags contains V1R4C5
memory search remains disabled
```
## Hard Stop
Do not create V1R4C6 tag.
Do not promote V1R4C6 baseline.
Do not deploy V1R4C6.
Do not enable memory search.
Proceed only after baseline visibility is reconciled.
@@ -0,0 +1,83 @@
# V1R4C6 Context Isolation Recovery and Resume Decision
Date: 2026-07-11
Status: approved to resume merge/deploy/verification; stop before tag/promotion
## Recovery Evidence
The unrelated OpenClaw/WeChat report was traced to notification/task isolation weaknesses rather than GitOps baseline corruption.
Actions completed:
1. The legacy `AIOS每日自治报告` cron was disabled and retained for forensic review.
2. The expired `2026-07-06-reminder` cron was archived and deleted after confirming no pending dependency.
3. Pending notification queue depth was verified as zero.
4. Unknown/unscoped queued message count was verified as zero.
5. A scoped V1R4C6 WeChat test notification was sent successfully.
The test notification included:
```text
task_id = V1R4C6-integration
release_id = V1R4C6
branch = release/v1r4c6-admin-search-agent-integration-20260711
baseline = V1R4C5
correlation_id = 70308f5e243ab81d
```
No recipient identifier, token, cookie, or unrelated message content is stored in this release record.
## Resume Decision
V1R4C6 may resume:
```text
merge
component tests
security/isolation tests
deployment
production verification
release evidence updates
```
V1R4C6 must still stop before:
```text
Git tag creation
baseline promotion
memory search activation
```
## Required Execution Context
Before every OpenClaw execution or WeChat notification, assert:
```text
task_id = V1R4C6-integration
release_id = V1R4C6
branch = release/v1r4c6-admin-search-agent-integration-20260711
baseline = V1R4C5
memory_search_enabled = false
```
Any mismatch re-enters safe mode and blocks execution/notification.
## Hard Constraints
```text
no force-push
no Gitea DB mutation
no V1R4C5 tag movement
no global memory/vector index
no cross-user/project/session notification or task data
```
## Owner Gate
After Gate A, Gate B, Gate C, deployment verification, runtime acceptance, and Gitea sentinel checks pass, OpenClaw must request explicit owner approval through the scoped WeChat channel for both:
1. Create V1R4C6 Git tag.
2. Promote active baseline from V1R4C5 to V1R4C6.
Ambiguous acknowledgements do not count as approval.
@@ -0,0 +1,136 @@
# V1R4C6 Integration Merge Matrix
Date: 2026-07-11
Status: integration-ready
Base baseline: V1R4C5
Base baseline git: `a32c76a3c1baa49562e53fb8adce10a5ed0e6704`
## Release Objective
Deliver three capability groups in one controlled release train:
1. Admin Workbench and Portal AIOS read-only rollout.
2. SR-03 Search Gateway real provider binding.
3. Agent Platform Integration.
This is a bundled release train, not an uncontrolled bulk merge. Each group has an independent acceptance gate and rollback switch.
## Group A — Admin Workbench and Portal AIOS
| Branch | Capability | Decision |
|---|---|---|
| `feature/v1r4c4-sr04-admin-workbench-visibility-20260711` | Admin workbench task progress and process visibility | integrate |
| `feature/v1r4c4-sr04-portal-aios-visibility-20260710` | Portal AIOS route/menu visibility | integrate |
| `patch/user-portal-me-route-diagnostics-20260709` | `/portal/me/aios/manifest`, dashboard and health route contract | integrate after route review |
| `patch/user-portal-rbac-ops-dashboard` | Admin/group-admin RBAC dashboard | integrate after RBAC review |
| `patch/agent-observability-webui` | Agent observability UI | integrate read-only |
| `patch/web-ui-complete-gitops-dashboard-20260706` | GitOps dashboard UI | integrate only if not superseded by the newer workbench implementation |
### Required user-visible result
Administrators can see:
- task progress;
- task/flow/process diagram entry;
- system health;
- authorized agent observability;
- explicit empty/error states.
### Security constraints
- Read-only first.
- RBAC is enforced server-side and client-side.
- No cross-user, cross-project, cross-session, or cross-tenant data exposure.
- Owner-only modules remain hidden from non-owner administrators.
## Group B — SR-03 Search Gateway Real Provider Binding
| Branch | Capability | Decision |
|---|---|---|
| `feature/v1r4c4-sr02-search-gateway-resilience-20260710` | Gateway resilience and safe fallback | integrate |
| `patch/search-gateway-governed-general-capability` | Governed provider binding and policy surface | integrate after scope review |
| `patch/gitops-change-archive-audit` | Change/archive audit evidence | integrate or retain as supporting audit module |
### Required result
- Real provider binding works.
- Query scope is enforced before ranking.
- Accepted records include provenance.
- Provider failure falls back to `text-only`, `no-memory`, or `scoped-only`.
- Global memory/vector indexes are not used.
### Security constraints
```text
AIOS_MEMORY_ENABLED=false
```
Search Gateway provider binding must not implicitly enable memory search.
## Group C — Agent Platform Integration
| Branch | Capability | Decision |
|---|---|---|
| `patch/agent-event-bus-unified` | Unified scoped agent event bus | integrate |
| `patch/agent-gpt-message-plane` | Scoped Agent/GPT message plane | integrate |
| `patch/autonomous-controller-dispatch-20260709` | Autonomous controller dispatch | integrate behind safe-mode gate |
| `patch/self-evolution-agent-orchestration-policy` | Agent orchestration and governance policy | integrate |
| `patch/aios-autonomous-orchestrator-architecture-review-v1` | Architecture record | integrate as documentation or mark superseded after review |
### Required result
- Agent event and message envelopes include user/project/session scope.
- Controller dispatch stops when autonomous safe mode is active.
- Consequential actions remain owner-gated.
- Observability is available before autonomous expansion.
## Explicitly Excluded
Do not merge or activate in V1R4C6:
- memory search activation;
- global vector index;
- cross-session/global recall;
- unscoped search or message delivery;
- old test/smoke/auth-only branches;
- old V1R2/V1R3 promotion requests;
- force-push reconciliation;
- Gitea database mutation.
## Merge and Deployment Order
```text
1. Verify V1R4C5 baseline and bare-repo configuration.
2. Integrate Group A read-only workbench/portal surface.
3. Run Group A RBAC and visibility acceptance.
4. Integrate Group B provider binding under isolation gate.
5. Run Group B scope/provenance/fallback acceptance.
6. Integrate Group C event/message/dispatch capabilities.
7. Run Group C scope/safe-mode/owner-gate acceptance.
8. Run combined runtime acceptance.
9. Prepare V1R4C6 tag and promotion request.
10. Stop for owner confirmation before tag/promotion.
```
## Combined Acceptance
Required:
```text
getRuntimeStatus -> active
getBaseline -> V1R4C5 before promotion
createBranch -> committed and synced
createPatch -> committed and synced
Gitea branch sentinel -> healthy
memory search -> disabled
```
## Rollback Switches
- Group A: disable new workbench routes/menu entries.
- Group B: disable real provider binding and use safe non-memory fallback.
- Group C: disable expanded dispatch and keep event/message plane observational.
- Memory search remains disabled in all rollback modes.
@@ -0,0 +1,51 @@
# V1R4C6 Release Acceptance Checklist
Date: 2026-07-11
## Gate A — Admin Workbench / Portal AIOS
| Check | Status | Notes |
|--------------------------------|---------|--------------------------------|
| Admin workbench RBAC read-only | NOT RUN | |
| Portal AIOS manifest route | NOT RUN | |
| Portal AIOS dashboard route | NOT RUN | |
| Flow/process diagram renders | NOT RUN | |
| Portal AIOS health route | NOT RUN | |
## Gate B — SR-03 Search Gateway
| Check | Status | Notes |
|------------------------------------|---------|--------------------------------|
| Provider binding correctness | NOT RUN | |
| Scope-before-ranking validation | NOT RUN | |
| Provenance correctness | NOT RUN | |
| Fallback to disabled provider | NOT RUN | |
## Gate C — Agent Platform
| Check | Status | Notes |
|------------------------------------|---------|--------------------------------|
| Event scope isolation | NOT RUN | |
| Message plane scope isolation | NOT RUN | |
| Dispatch safe-mode respects control | NOT RUN | |
| Owner gate for consequential ops | NOT RUN | |
## Final Deployment Gates
| Check | Status | Notes |
|---------------------------|-----------|--------------------------------|
| Tag V1R4C6 | NO | Requires owner approval |
| Baseline promotion | NO | Requires owner approval |
| Memory search enabled | NO | Requires future owner approval |
## Active Constraints
- AIOS_MEMORY_ENABLED: false
- Force-push: forbidden
- Gitea DB mutation: owner approval required
- Memory search: disabled (future owner approval required for enable)
## Artifact
- **PR #6 merge commit**: `ae49bf0fa35b0f599a96a4d12644576447f39cec`
- **Source baseline**: V1R4C5 (commit a32c76a3c1baa49562e53fb8adce10a5ed0e6704)
@@ -0,0 +1,92 @@
# V1R4C6 Release Gate Status
Date: 2026-07-11
Status: integration pending — release-candidate control artifacts merged, engineering gates not yet executed
## Completed
- V1R4C5 baseline verified as active base.
- GitOps API and public API return:
- current_baseline: V1R4C5
- previous_baseline: V1R4C3
- git: a32c76a3c1baa49562e53fb8adce10a5ed0e6704
- GitOps bare repository path corrected to active `aios.git`.
- V1R4C6 merge matrix created.
- V1R4C6 acceptance checklist created.
- V1R4C6 acceptance evidence created.
- PR #6: control artifacts merge — PASS
## Result Summary
| Item | Status |
|-------------------------|-----------|
| control artifacts merge | PASS |
| Gate A | NOT RUN |
| Gate B | NOT RUN |
| Gate C | NOT RUN |
| tests | NOT RUN |
| artifact | NOT CREATED |
| deployment | NOT RUN |
| production verification | NOT RUN |
| tag created | NO |
| baseline promoted | NO |
| memory search enabled | NO |
| **overall** | **BLOCKED / INTEGRATION PENDING** |
## Verified Artifact
- **PR #6 merge commit**: `ae49bf0fa35b0f599a96a4d12644576447f39cec`
- **Current baseline**: V1R4C5
- **V1R4C6 tag**: absent
- **Memory search**: disabled
- **Force-push**: disabled
## Remaining Gates (not yet executed)
### Gate A — Admin Workbench / Portal AIOS
- merge candidate review;
- RBAC acceptance;
- task progress visibility;
- flow diagram visibility;
- health/observability validation.
### Gate B — SR-03 Search Gateway
- provider binding validation;
- scope-before-ranking validation;
- provenance validation;
- fallback validation.
### Gate C — Agent Platform
- event scope validation;
- message plane scope validation;
- dispatch safe-mode validation;
- owner gate validation.
## Release Stop Point
After Gates A/B/C pass:
```text
prepare V1R4C6 tag
↓
owner confirmation required
↓
tag creation
↓
baseline promotion
```
## Hard Constraints
Remain:
```text
AIOS_MEMORY_ENABLED=false
memory search disabled
force-push forbidden
gitea DB mutation requires owner approval
```
@@ -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,152 @@
apiVersion: aios.gitops/v1
kind: ExecutionOrder
metadata:
name: openclaw-v1r4c6-merge-tests-deploy
date: 2026-07-11
owner_approval:
branch: request/openclaw-v1r4c6-merge-deploy-20260711
file: approvals/openclaw-v1r4c6-merge-deploy-owner-approval.yaml
commit: 4653f92
spec:
objective: >-
Integrate the approved V1R4C6 Admin Workbench, Search Gateway, and Agent Platform
changes into a clean candidate based on the latest main, execute all mandatory gates,
merge to main only after success, then deploy and verify production.
confirmation_policy: no_additional_confirmation_required
source:
primary_branch: release/v1r4c6-admin-search-agent-integration-20260711
supporting_domains:
- admin-workbench-portal-aios
- search-gateway-governance
- agent-platform-integration
target:
integration_branch: integration/v1r4c6-merge-deploy-20260711-01
final_branch: main
release_version: V1R4C6
execution:
- id: preflight
actions:
- fetch_all_refs_and_tags
- verify_workspace_clean
- reset_integration_candidate_to_latest_main
- verify_baseline_is_V1R4C5
- verify_memory_search_enabled_is_false
- verify_V1R4C5_tag_unchanged
- record_main_head_and_source_head
stop_conditions:
- dirty_workspace
- missing_source_branch
- baseline_not_V1R4C5
- protected_setting_changed
- id: semantic-diff-review
actions:
- compute_three_dot_diff_from_main_to_primary_branch
- classify_changes_by_admin_search_agent_domains
- detect_duplicate_control_artifacts_already_merged_by_PR_6
- preserve_PR_7_NOT_RUN_status_until_tests_execute
- reject_reversion_of_V1R4C5_baseline_metadata
- reject_enablement_of_memory_search
output:
- execution/evidence/v1r4c6-semantic-diff-report.md
- id: merge-candidate
actions:
- merge_primary_branch_into_integration_candidate_without_fast_forward
- resolve_text_conflicts_by_preserving_latest_main_control_metadata
- preserve_source_business_functionality_when_not_conflicting_with_safety_controls
- commit_resolved_candidate
forbidden:
- force_push
- direct_commit_to_main
- bypass_branch_protection
- bypass_owner_gate
- weaken_rbac_or_scope_isolation
- mark_unexecuted_tests_as_passed
- id: gate-a-admin
required_checks:
- admin_workbench_routes_and_assets_load
- portal_aios_manifest_dashboard_health_endpoints
- server_side_and_frontend_rbac
- empty_error_and_loading_states
- tenant_user_project_session_isolation
- id: gate-b-search
required_checks:
- real_provider_binding_or_explicit_safe_fallback
- timeout_limited_retry_and_safe_fallback
- scope_filter_before_ranking
- provenance_in_results
- provider_and_fallback_audit
- unauthorized_results_never_enter_ranking_or_summary
- memory_search_remains_disabled
- id: gate-c-agent
required_checks:
- unified_agent_event_bus
- scoped_agent_gpt_message_plane
- autonomous_controller_dispatch
- safe_mode_stops_dispatch_and_change
- high_risk_actions_remain_owner_gated
- duplicate_dispatch_is_idempotent_or_rejected
- queue_failure_retry_observability
- id: runtime-and-regression
actions:
- run_getRuntimeStatus
- run_getBaseline
- run_createBranch_smoke_test
- run_createPatch_smoke_test
- run_full_regression_suite
- run_deployment_preflight_health_checks
expected:
reads: "200 OK with service, status, workspace_branch"
writes: "200 OK with branch, commit, rev, sync_status"
- id: promote-main
conditions:
- gate_a_passed
- gate_b_passed
- gate_c_passed
- runtime_acceptance_passed
- regression_passed
- deployment_preflight_passed
actions:
- open_or_update_merge_request_from_integration_candidate_to_main
- merge_without_rewriting_history
- record_new_main_head
- update_versions_V1R4C6_json_to_release_ready_or_active_baseline_as_policy_requires
- update_baseline_current_json_only_after_successful_promotion
- create_V1R4C6_tag_according_to_repository_tag_semantics
- id: deploy-and-verify
actions:
- trigger_direct_deployment_from_new_main
- verify_service_health
- verify_admin_search_agent_smoke_paths
- verify_runtime_status_and_baseline_after_deploy
- verify_rollback_artifact_and_previous_baseline_are_available
- publish_production_verification_report
failure_policy:
mode: fail_closed
on_any_failure:
- stop_pipeline
- do_not_update_main
- do_not_create_or_move_release_tag
- do_not_deploy
- preserve_integration_branch_for_forensics
- commit_failure_evidence_and_exact_blocker
reporting:
required_files:
- execution/evidence/v1r4c6-semantic-diff-report.md
- execution/evidence/gate-a-admin-result.md
- execution/evidence/gate-b-search-result.md
- execution/evidence/gate-c-agent-result.md
- execution/evidence/runtime-acceptance-result.md
- execution/evidence/regression-result.md
- execution/evidence/deployment-result.md
- execution/evidence/production-verification-result.md
final_summary_fields:
- source_head
- integration_head
- main_head_before
- main_head_after
- merge_commit
- release_tag
- deployment_id
- gate_results
- production_health
- rollback_ready
+112
View File
@@ -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())
+2726
View File
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,429 @@
# CMD-20260711-v1r4c6-integrate-deploy-verify
## Goal
Perform the actual controlled integration, deployment, and verification for V1R4C6.
V1R4C6 includes:
1. Admin Workbench / Portal AIOS read-only rollout.
2. SR-03 Search Gateway real provider binding.
3. Agent Platform Integration.
Stop before V1R4C6 Git tag creation and baseline promotion. Those operations require owner confirmation.
## Hard Constraints
```text
AIOS_MEMORY_ENABLED=false
memory search remains disabled
no global vector index
no force-push
no V1R4C5 tag movement
no Gitea DB mutation
no secret plaintext in Git, logs, or reports
```
If any mandatory security or reliability check fails, stop deployment and report the blocker. Do not bypass the gate.
## Verified Base
Required before starting:
```text
current_baseline = V1R4C5
previous_baseline = V1R4C3
baseline_git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704
AIOS_GITOPS_BARE_REPO = /data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git
runtime = active
```
## Phase 0 — Preflight and Backup
```bash
set -euo pipefail
ROOT=/data/aios-gitops/workspace
SOURCE_RELEASE=release/v1r4c6-admin-search-agent-integration-20260711
EXEC_BRANCH=release/v1r4c6-integration-exec-20260711
cd "$ROOT"
git fetch origin --tags --prune
git status --short
test -f baseline/current.json
python3 - <<'PY'
import json
from pathlib import Path
p = Path('baseline/current.json')
d = json.loads(p.read_text(encoding='utf-8'))
assert d['current_baseline'] == 'V1R4C5', d
assert d['previous_baseline'] == 'V1R4C3', d
assert d['git'] == 'a32c76a3c1baa49562e53fb8adce10a5ed0e6704', d
assert d.get('constraints', {}).get('memory_search_enabled') is False, d
print('baseline preflight: PASS')
PY
test -d /data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git
BACKUP=/data/aios-gitops/runtime/backups/v1r4c6-predeploy-$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP"
cp -a baseline/current.json "$BACKUP/current.json"
cp -a versions/V1R4C5.json "$BACKUP/V1R4C5.json"
if git show-ref --verify --quiet "refs/heads/$EXEC_BRANCH"; then
git checkout "$EXEC_BRANCH"
git pull --ff-only origin "$EXEC_BRANCH"
else
git checkout -b "$EXEC_BRANCH" "origin/$SOURCE_RELEASE"
git push -u origin "$EXEC_BRANCH"
fi
```
Never use `git push --force` or `git tag -f`.
## Phase 1 — Merge Group A: Admin Workbench / Portal AIOS
Merge in this order:
```bash
git merge --no-ff origin/feature/v1r4c4-sr04-admin-workbench-visibility-20260711 \
-m 'merge(v1r4c6): admin workbench visibility preparation'
git merge --no-ff origin/feature/v1r4c4-sr04-portal-aios-visibility-20260710 \
-m 'merge(v1r4c6): portal AIOS visibility preparation'
git merge --no-ff origin/patch/user-portal-me-route-diagnostics-20260709 \
-m 'merge(v1r4c6): portal AIOS routes and RBAC manifest'
git merge --no-ff origin/patch/user-portal-rbac-ops-dashboard \
-m 'merge(v1r4c6): portal RBAC operations dashboard'
git merge --no-ff origin/patch/agent-observability-webui \
-m 'merge(v1r4c6): agent observability web UI'
```
Before merging `patch/web-ui-complete-gitops-dashboard-20260706`, compare overlap:
```bash
git diff --stat HEAD..origin/patch/web-ui-complete-gitops-dashboard-20260706
```
Merge only if it adds required functionality not already present. Otherwise record it as superseded.
### Conflict Policy
Do not auto-resolve conflicts in:
```text
auth/RBAC configuration
user/session/tenant isolation
search provider configuration
safe-mode/controller dispatch
systemd units
baseline/current.json
versions/*.json
```
For those files, stop and report conflict paths.
### Group A Tests
Run at minimum:
```bash
python3 -m pytest -q \
tests/test_portal_aios_frontend_contract.py \
tests/test_portal_aios_module_registry.py \
tests/test_portal_aios_navigation.py \
tests/test_portal_integration_health.py \
tests/test_portal_system_health_adapter.py
```
Run any additional portal/RBAC/dashboard tests discovered under `tests/`.
## Phase 2 — Merge Group B: SR-03 Search Gateway
```bash
git merge --no-ff origin/feature/v1r4c4-sr02-search-gateway-resilience-20260710 \
-m 'merge(v1r4c6): search gateway resilience metadata'
git merge --no-ff origin/patch/search-gateway-governed-general-capability \
-m 'merge(v1r4c6): governed search gateway provider binding'
git merge --no-ff origin/patch/gitops-change-archive-audit \
-m 'merge(v1r4c6): search and GitOps audit evidence'
```
### Group B Security Review
Verify before deployment:
```text
scope filtering occurs before ranking
provenance is attached to accepted results
out-of-scope records never reach ranking or summarization
fallback is text-only, no-memory, or scoped-only
global memory/vector index is disabled
memory search remains disabled
provider credentials are secret references only
```
Reject any code path that silently broadens search scope.
### Group B Tests
Discover and run all search/provider/isolation tests:
```bash
find tests -maxdepth 2 -type f \
\( -iname '*search*' -o -iname '*provider*' -o -iname '*scope*' -o -iname '*isolation*' \) \
-print
```
Then run the discovered files with `python3 -m pytest -q`.
Also run:
```bash
bash ops/ci/run-user-data-isolation-acceptance.sh
```
If that script is not present after merge, stop: the data-isolation gate is mandatory.
## Phase 3 — Merge Group C: Agent Platform
```bash
git merge --no-ff origin/patch/agent-event-bus-unified \
-m 'merge(v1r4c6): unified scoped agent event bus'
git merge --no-ff origin/patch/agent-gpt-message-plane \
-m 'merge(v1r4c6): scoped agent GPT message plane'
git merge --no-ff origin/patch/autonomous-controller-dispatch-20260709 \
-m 'merge(v1r4c6): autonomous controller dispatch with gates'
git merge --no-ff origin/patch/self-evolution-agent-orchestration-policy \
-m 'merge(v1r4c6): self-evolution orchestration policy'
```
Review `patch/aios-autonomous-orchestrator-architecture-review-v1`:
- merge if it contains a current architecture record not already present;
- otherwise mark it superseded in the release report.
### Group C Security Review
Verify:
```text
event envelope includes tenant/group, user, project, session, agent, source and provenance
message plane rejects cross-scope delivery
denied messages never forward body/content
controller dispatch checks autonomous safe mode before mutation
consequential actions remain owner-gated
duplicate dispatch is idempotent or rejected
queue/retry behavior is bounded and observable
```
### Group C Tests
Discover and run agent/event/message/dispatch/controller tests:
```bash
find tests -maxdepth 2 -type f \
\( -iname '*agent*' -o -iname '*event*' -o -iname '*message*' -o -iname '*dispatch*' -o -iname '*autonomous_controller*' \) \
-print
```
Run all discovered files with `python3 -m pytest -q`.
Re-run the SR-02 watcher/controller suite and require all tests to pass.
## Phase 4 — Full Test and Static Validation
```bash
python3 -m compileall -q active-runtime scripts tests
python3 -m pytest -q
```
If the full suite has unrelated historical failures, report them separately, but all V1R4C6-touched component tests must pass.
Validate JSON/YAML configuration without printing secrets.
Verify:
```bash
git diff --check
git status --short
```
Commit integration result normally:
```bash
git add -A
git commit -m 'release: integrate V1R4C6 admin search and agent platform'
git push origin "$EXEC_BRANCH"
```
If no uncommitted changes remain because merge commits already captured everything, just push normally.
## Phase 5 — Deploy to Staging/Production Runtime
Use the existing deployment mechanism for this repository. Do not invent a new deployment path.
Before restart/reload, identify changed services and frontend bundles from the merged diff.
At minimum:
1. Deploy backend modules under `active-runtime/app/`.
2. Deploy portal frontend/navigation/proxy changes.
3. Deploy Search Gateway provider binding using secret references.
4. Deploy Agent Platform event/message/dispatch changes.
5. Preserve existing Gitea sentinel and user-data isolation systemd controls.
6. Keep `AIOS_MEMORY_ENABLED=false`.
Restart only affected services. Do not restart Gitea unless it independently fails health checks.
After deployment, verify affected services are active and no restart loop exists.
## Phase 6 — Actual Deployment Verification
### Global
```bash
curl -fsS http://127.0.0.1:18890/api/base | python3 -m json.tool
curl -fsS https://cnnetmkt.top/api/base | python3 -m json.tool
```
Before V1R4C6 promotion both must still report V1R4C5.
Verify Gitea sentinel remains healthy and memory search remains disabled.
### Gate A — Portal/Admin Workbench
Use an existing authenticated test mechanism or secret-backed admin session. Do not log cookies/tokens.
Verify:
```text
/portal/me/aios/manifest
/portal/me/aios/dashboard
/portal/me/aios/health
```
For `user`, `group_admin/admin`, and `owner`, record only module names/status, not personal data.
Required admin-visible functions:
```text
task progress
process/flow diagram entry
system health
agent observability
```
Verify forbidden owner modules are hidden from non-owner admins.
### Gate B — Search Gateway
Execute scoped test queries using non-sensitive fixtures.
Verify:
```text
real provider selected
scope applied before ranking
provenance returned
out-of-scope fixture denied
safe fallback works
memory search remains disabled
```
### Gate C — Agent Platform
Using synthetic non-sensitive events/messages:
```text
same-scope delivery succeeds
cross-user/project/session delivery is denied
denied payload body is not forwarded
safe-mode blocks controller mutation
owner-gated action remains pending approval
duplicate dispatch is idempotent/rejected
```
## Phase 7 — Release Evidence
Update:
```text
docs/releases/V1R4C6-acceptance-checklist.md
docs/releases/V1R4C6-acceptance-evidence.md
docs/releases/V1R4C6-release-gate-status.md
versions/V1R4C6.json
```
Set each gate to PASS/FAIL with concrete test commands and results.
Required final state before requesting owner confirmation:
```text
Gate A = PASS
Gate B = PASS
Gate C = PASS
full touched-component tests = PASS
runtime write acceptance = PASS
Gitea sentinel = healthy
memory search = disabled
force-push used = no
V1R4C6 tag = absent
active baseline = V1R4C5
```
Commit and push evidence normally to `$EXEC_BRANCH`.
## Stop Point — Owner Confirmation Required
Do not create `V1R4C6` tag.
Do not promote baseline to V1R4C6.
Do not enable memory search.
When all gates pass, notify the owner through the configured notification mechanism, preferably WeChat if configured. The notification must contain no secrets and must request confirmation for:
```text
Create V1R4C6 Git tag
Promote active baseline from V1R4C5 to V1R4C6
```
Required report fields:
```text
Status
Integration branch and HEAD
Merged branches
Skipped/superseded branches
Gate A result
Gate B result
Gate C result
Test totals
Deployment services/build versions
Portal/admin verification result
Search provider verification result
Agent platform verification result
Gitea sentinel status
Memory search status
Active baseline
V1R4C6 tag status
Rollback readiness
Owner confirmation required
```
If WeChat notification is not configured, write the exact notification message to:
```text
/data/aios-gitops/runtime/state/V1R4C6-owner-confirmation-message.txt
```
and report that path.
@@ -0,0 +1,217 @@
# CMD-20260711-v1r4c6-run-to-owner-gate-wechat
## Execution Mode
Execute immediately using the existing V1R4C6 integration command:
```text
ops/openclaw/CMD-20260711-v1r4c6-integrate-deploy-verify.md
```
Continue through actual merge, tests, deployment, and production verification.
Stop only at the owner gate for:
```text
V1R4C6 Git tag creation
V1R4C6 baseline promotion
```
Do not ask the owner to perform routine engineering or deployment work.
## Verified Starting State
Required before starting:
```text
runtime = active
current_baseline = V1R4C5
previous_baseline = V1R4C3
baseline_git = a32c76a3c1baa49562e53fb8adce10a5ed0e6704
AIOS_GITOPS_BARE_REPO = /data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git
memory_search_enabled = false
force_push_allowed = false
```
If any starting condition differs, stop and send a WeChat blocker notification.
## Required Work
Complete all phases in the referenced command:
1. Create/update the normal non-force-pushed integration execution branch.
2. Merge and validate Admin Workbench / Portal AIOS.
3. Merge and validate SR-03 Search Gateway real provider binding.
4. Merge and validate Agent Platform Integration.
5. Run component and full touched-scope test suites.
6. Deploy using the repository's existing deployment mechanism.
7. Verify production routes, RBAC, scope isolation, provenance, safe mode, and owner gates.
8. Update V1R4C6 release evidence and version metadata.
9. Push all normal commits to origin.
10. Keep V1R4C6 tag absent and baseline at V1R4C5 until owner approval.
## Hard Constraints
```text
AIOS_MEMORY_ENABLED=false
memory search remains disabled
no global memory/vector index
no force-push
no tag rewrite
no V1R4C5 tag movement
no Gitea DB mutation
no plaintext secrets in Git, logs, reports, or WeChat
```
Never bypass a failed security, isolation, RBAC, safe-mode, or owner gate.
## WeChat Progress Notifications
Use the configured OpenClaw WeChat notification channel.
Send concise progress notifications at these milestones:
### Notification 1 — Integration Started
```text
V1R4C6 集成已启动。
范围:管理员工作台、SR-03 Search Gateway、Agent Platform。
当前基线:V1R4C5。
memory search 保持关闭。
下一步:完成合并、测试和部署验证。
```
### Notification 2 — Gate A Complete
Include only:
```text
Gate A result: PASS/FAIL
Admin task progress visibility: PASS/FAIL
Flow diagram visibility: PASS/FAIL
RBAC/isolation: PASS/FAIL
Blockers, if any
```
Do not include user records, tokens, cookies, or sensitive task content.
### Notification 3 — Gate B Complete
Include only:
```text
Gate B result: PASS/FAIL
Real provider binding: PASS/FAIL
Scope-before-ranking: PASS/FAIL
Provenance: PASS/FAIL
Safe fallback: PASS/FAIL
Memory search: disabled
Blockers, if any
```
### Notification 4 — Gate C Complete
Include only:
```text
Gate C result: PASS/FAIL
Event scope: PASS/FAIL
Message plane scope: PASS/FAIL
Safe-mode dispatch block: PASS/FAIL
Owner gate: PASS/FAIL
Blockers, if any
```
## Final WeChat Owner Authorization Request
Send only after all mandatory gates and deployment verification pass.
Use this message:
```text
V1R4C6 已完成实际合并、测试、部署和生产验证,现等待最终发布授权。
版本范围:
1. 管理员工作台 / Portal AIOS 上线
2. SR-03 Search Gateway Real Provider Binding
3. Agent Platform Integration
验收结果:
- Gate A(管理员工作台 / RBAC / 隔离):PASS
- Gate B(Provider / scope / provenance / fallback):PASS
- Gate C(Event Bus / Message Plane / Dispatch / Owner Gate):PASS
- Runtime 写链路:PASS
- Gitea consistency sentinel:healthy
- memory search:仍为 disabled
- force-push:未使用
- 当前 active baseline:V1R4C5
- V1R4C6 tag:尚未创建
请明确回复以下其中一项:
A. 批准创建 V1R4C6 Git tag,并将 active baseline 从 V1R4C5 晋升至 V1R4C6。
B. 暂不批准,保持 V1R4C5,并说明需要处理的问题。
```
## Authorization Rules
Do not treat reactions, emojis, acknowledgements, or ambiguous replies as approval.
Valid approval must explicitly authorize both:
```text
create V1R4C6 Git tag
promote baseline V1R4C5 -> V1R4C6
```
If the owner approves only one operation, execute only the approved operation and stop before the other.
Before executing a consequential operation, record authorization in:
```text
docs/releases/V1R4C6-owner-approval.md
```
Record only:
```text
approved operation(s)
approved_at
approval channel = wechat
message/conversation identifier if available
owner identity reference without copying personal message content
memory search remains disabled
```
Do not store access tokens, cookies, phone numbers, or unrelated chat content.
## WeChat Failure Handling
If the configured WeChat channel is unavailable:
1. Do not claim that a message was sent.
2. Stop before tag/promotion.
3. Write the exact final authorization request to:
```text
/data/aios-gitops/runtime/state/V1R4C6-owner-confirmation-message.txt
```
4. Write notification failure details to:
```text
/data/aios-gitops/runtime/state/V1R4C6-wechat-notification-failure.json
```
5. Report the failure through the active GitOps task/status channel.
## Final Stop Condition
Before explicit owner authorization, final state must remain:
```text
current_baseline = V1R4C5
V1R4C6 tag = absent
memory search = disabled
```
After owner authorization, follow the existing tag confirmation and baseline promotion controls. Do not bypass consequential-operation confirmation layers.
@@ -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,9 @@
{
"type": "branch",
"branch": "reconcile/v1r4c5-release-to-main-20260711",
"base": "main",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-10T23:56:41Z",
"direct_main_push": false,
"owner_approval_required": true
}
@@ -0,0 +1,9 @@
{
"id": "patch-1783707996243-33717dfe",
"patch_name": "v1r4c6-admin-search-agent-integration-20260711",
"branch": "release/v1r4c6-admin-search-agent-integration-20260711",
"risk": "high",
"description": "V1R4C6 bundled integration release: Admin Workbench visibility, SR-03 Search Gateway real provider binding, and Agent Platform Integration. Memory search remains disabled and user data isolation gates remain mandatory.",
"created_by": "chatgpt-gitops-bot",
"created_at": "2026-07-10T18:26:36Z"
}
+145
View File
@@ -0,0 +1,145 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AIOS Orchestrator Decision",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"project_key",
"contract_commit",
"event_id",
"expected_state_version",
"verdict",
"state_updates",
"next_work_units",
"requires_human_input",
"stop_reason"
],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"project_key": {
"type": "string"
},
"contract_commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"event_id": {
"type": "string"
},
"expected_state_version": {
"type": "integer",
"minimum": 0
},
"verdict": {
"type": "string",
"enum": [
"BLOCKED",
"COMPLETE",
"DISPATCH",
"NOOP"
]
},
"state_updates": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "string"
}
}
}
},
"next_work_units": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"track_id",
"gate_id",
"primary_agent",
"fallback_agent",
"clean_base_commit",
"allowed_paths",
"required_checks",
"forbidden_operations",
"instruction"
],
"properties": {
"id": {
"type": "string"
},
"track_id": {
"type": "string"
},
"gate_id": {
"type": "string"
},
"primary_agent": {
"type": "string",
"enum": [
"openclaw",
"codex"
]
},
"fallback_agent": {
"type": "string",
"enum": [
"openclaw",
"codex"
]
},
"clean_base_commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"allowed_paths": {
"type": "array",
"items": {
"type": "string"
}
},
"required_checks": {
"type": "array",
"items": {
"type": "string"
}
},
"forbidden_operations": {
"type": "array",
"items": {
"type": "string"
}
},
"instruction": {
"type": "string"
}
}
}
},
"requires_human_input": {
"type": "boolean"
},
"stop_reason": {
"type": [
"string",
"null"
]
}
}
}
+102
View File
@@ -0,0 +1,102 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AIOS Orchestrator Event",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"project_key",
"contract_commit",
"event_id",
"source_agent",
"event_type",
"work_unit_id",
"status",
"state_version",
"implementation_commit",
"result_commit",
"blocker",
"evidence_refs",
"payload"
],
"properties": {
"schema_version": {
"const": 1
},
"project_key": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"contract_commit": {
"type": "string",
"pattern": "^[0-9a-f]{40}$"
},
"event_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"source_agent": {
"enum": [
"openclaw",
"codex",
"gitea",
"system",
"workspace-agent"
]
},
"event_type": {
"type": "string"
},
"work_unit_id": {
"type": "string"
},
"status": {
"enum": [
"READY",
"RUNNING",
"PASS",
"BLOCKED",
"FAIL",
"TIMEOUT",
"NOT_EXECUTED"
]
},
"state_version": {
"type": "integer",
"minimum": 0
},
"implementation_commit": {
"type": [
"string",
"null"
],
"pattern": "^[0-9a-f]{40}$"
},
"result_commit": {
"type": [
"string",
"null"
],
"pattern": "^[0-9a-f]{40}$"
},
"blocker": {
"type": [
"string",
"null"
],
"maxLength": 512
},
"evidence_refs": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 64
},
"payload": {
"type": "object"
}
}
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Seed exactly one orchestration event when the project has no durable state."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
from services.aios_orchestrator_gateway.config import Config
from services.aios_orchestrator_gateway.models import Event
from services.aios_orchestrator_gateway.securefs import atomic_write_bytes_shared
from services.aios_orchestrator_gateway.store import Store
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, required=True)
args = parser.parse_args()
config = Config.from_file(args.config)
store = Store(config.database_path)
if store.project_state(config.project_key) is not None:
print("ORCHESTRATION_BOOTSTRAP=NOOP reason=STATE_EXISTS")
return 0
event = Event.from_dict(
{
"schema_version": 1,
"project_key": config.project_key,
"contract_commit": config.contract_commit,
"event_id": "bootstrap-" + config.contract_commit[:24],
"source_agent": "system",
"event_type": "orchestration_bootstrap",
"work_unit_id": "ORCHESTRATION-BOOTSTRAP",
"status": "READY",
"state_version": 0,
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {
"reason": "initial_gateway_install",
"polling_enabled": True,
"openclaw_sync_required": True,
},
}
)
path = config.event_inbox_dir / "pending" / f"{event.event_id}.json"
atomic_write_bytes_shared(
path,
(event.canonical_json() + "\n").encode(),
mode=0o640,
group_writable_directory=True,
)
print(f"ORCHESTRATION_BOOTSTRAP=PASS event_id={event.event_id}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0077
SOURCE_ROOT="${SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
CONFIG_ROOT="${CONFIG_ROOT:-/etc/aios-orchestrator-gateway}"
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
UNIT_ROOT="${UNIT_ROOT:-/etc/systemd/system}"
SYSUSERS_ROOT="${SYSUSERS_ROOT:-/etc/sysusers.d}"
TMPFILES_ROOT="${TMPFILES_ROOT:-/etc/tmpfiles.d}"
DRY_RUN=0
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=1
fi
fail() {
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=%s\n' "$1" >&2
exit 2
}
[[ "$(id -u)" -eq 0 ]] || fail OWNER_INSTALL_REQUIRED
for cmd in git python3 systemd-analyze systemctl systemd-sysusers systemd-tmpfiles sha256sum find sort xargs; do
command -v "$cmd" >/dev/null 2>&1 || fail "MISSING_COMMAND_${cmd}"
done
if [[ -f "$SOURCE_ROOT/SHA256SUMS" ]]; then
(cd "$SOURCE_ROOT" && sha256sum --check SHA256SUMS)
fi
for path in \
"$SOURCE_ROOT/services/aios_orchestrator_gateway" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json"; do
[[ -e "$path" ]] || fail "MISSING_SOURCE_${path}"
done
PYCACHE="/tmp/aios-orchestrator-install-pycache-$$"
rm -rf "$PYCACHE"
PYTHONPYCACHEPREFIX="$PYCACHE" python3 -m compileall -q "$SOURCE_ROOT/services" "$SOURCE_ROOT/scripts"
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$SOURCE_ROOT" \
python3 -m unittest -v tests.test_aios_orchestrator_gateway
rm -rf "$PYCACHE"
for script in "$SOURCE_ROOT"/scripts/*.sh; do bash -n "$script"; done
python3 -m json.tool "$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" >/dev/null
systemd-analyze verify \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer"
SOURCE_FILES=(
README-AIOS-ORCHESTRATOR-GATEWAY.md
config/aios-orchestrator-gateway.example.json
schemas/decision.schema.json
schemas/event.schema.json
scripts/bootstrap_orchestration_loop.py
scripts/install_aios_orchestrator_gateway.sh
scripts/report_orchestrator_event.py
scripts/rollback_aios_orchestrator_gateway.sh
scripts/verify_aios_orchestrator_gateway.sh
systemd/aios-orchestrator-gateway-poll.service
systemd/aios-orchestrator-gateway-poll.timer
systemd/aios-orchestrator-gateway.service
systemd/aios-orchestrator-gateway.sysusers
systemd/aios-orchestrator-gateway.tmpfiles
tests/test_aios_orchestrator_gateway.py
tests/test_aios_orchestrator_gateway_rollback.sh
workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md
workspace-agent/orchestrator-decision-action.openapi.yaml
)
while IFS= read -r path; do SOURCE_FILES+=("$path"); done < <(
cd "$SOURCE_ROOT"
find services/aios_orchestrator_gateway -type f ! -path '*/__pycache__/*' ! -name '*.pyc' -print | sort
)
for path in "${SOURCE_FILES[@]}"; do
[[ -f "$SOURCE_ROOT/$path" && ! -L "$SOURCE_ROOT/$path" ]] || fail "INVALID_SOURCE_FILE_${path}"
done
SOURCE_DIGEST="$(
cd "$SOURCE_ROOT"
printf '%s\0' "${SOURCE_FILES[@]}" | sort -z | xargs -0 sha256sum \
| sha256sum \
| awk '{print $1}'
)"
[[ "$SOURCE_DIGEST" =~ ^[0-9a-f]{64}$ ]] || fail SOURCE_DIGEST_INVALID
RELEASE_DIR="$INSTALL_ROOT/releases/$SOURCE_DIGEST"
if [[ "$DRY_RUN" -eq 1 ]]; then
printf 'AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS digest=%s release=%s\n' \
"$SOURCE_DIGEST" "$RELEASE_DIR"
exit 0
fi
install -d -m 0755 "$INSTALL_ROOT/releases"
install -d -m 0700 "$CONFIG_ROOT"
install -d -m 0700 "$STATE_ROOT"
systemd-sysusers "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers"
systemd-tmpfiles --create "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles"
if [[ ! -d "$RELEASE_DIR" ]]; then
TMP_RELEASE="$INSTALL_ROOT/releases/.tmp-$SOURCE_DIGEST-$$"
install -d -m 0755 "$TMP_RELEASE"
for path in "${SOURCE_FILES[@]}"; do
install -d -m 0755 "$TMP_RELEASE/$(dirname "$path")"
cp -a "$SOURCE_ROOT/$path" "$TMP_RELEASE/$path"
done
printf '%s\n' "$SOURCE_DIGEST" > "$TMP_RELEASE/SOURCE_DIGEST"
chmod -R go-w "$TMP_RELEASE"
mv "$TMP_RELEASE" "$RELEASE_DIR"
fi
PREVIOUS_TARGET=""
if [[ -L "$INSTALL_ROOT/current" ]]; then
PREVIOUS_TARGET="$(readlink -f "$INSTALL_ROOT/current")"
fi
ln -s "$RELEASE_DIR" "$INSTALL_ROOT/.current-new-$$"
mv -Tf "$INSTALL_ROOT/.current-new-$$" "$INSTALL_ROOT/current"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
"$UNIT_ROOT/aios-orchestrator-gateway.service"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
"$UNIT_ROOT/aios-orchestrator-gateway-poll.service"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
"$UNIT_ROOT/aios-orchestrator-gateway-poll.timer"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
"$SYSUSERS_ROOT/aios-orchestrator-gateway.conf"
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
"$TMPFILES_ROOT/aios-orchestrator-gateway.conf"
if [[ ! -f "$CONFIG_ROOT/inbound.token" ]]; then
python3 - <<'PY' > "$CONFIG_ROOT/inbound.token"
import secrets
print(secrets.token_urlsafe(48))
PY
fi
[[ ! -L "$CONFIG_ROOT/inbound.token" ]] || fail INBOUND_TOKEN_SYMLINK
chown root:aios-orchestrator "$CONFIG_ROOT/inbound.token"
chmod 0640 "$CONFIG_ROOT/inbound.token"
if [[ ! -f "$CONFIG_ROOT/callback.token" ]]; then
python3 - <<'PY' > "$CONFIG_ROOT/callback.token"
import secrets
print(secrets.token_urlsafe(48))
PY
fi
[[ ! -L "$CONFIG_ROOT/callback.token" ]] || fail CALLBACK_TOKEN_SYMLINK
chown root:aios-orchestrator "$CONFIG_ROOT/callback.token"
chmod 0640 "$CONFIG_ROOT/callback.token"
if [[ ! -f "$CONFIG_ROOT/config.json" ]]; then
install -m 0600 \
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" \
"$CONFIG_ROOT/config.json.example"
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=CONFIGURATION_REQUIRED digest=%s\n' \
"$SOURCE_DIGEST"
exit 2
fi
chown root:aios-orchestrator "$CONFIG_ROOT/config.json"
chmod 0640 "$CONFIG_ROOT/config.json"
python3 -m json.tool "$CONFIG_ROOT/config.json" >/dev/null
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
PYTHONPATH="$INSTALL_ROOT/current" \
python3 - "$CONFIG_ROOT/config.json" <<'PY'
import sys
from pathlib import Path
from services.aios_orchestrator_gateway.config import Config
cfg = Config.from_file(Path(sys.argv[1]))
cfg.inbound_token()
cfg.callback_token()
if cfg.workspace_trigger_id:
cfg.workspace_token()
if cfg.responses_api_key_file:
cfg.responses_api_key()
if cfg.openclaw_sync_endpoint:
cfg.openclaw_sync_token()
print("CONFIG_VALIDATE=PASS")
PY
python3 - "$STATE_ROOT/install-receipt.json" "$SOURCE_DIGEST" "$RELEASE_DIR" "$PREVIOUS_TARGET" <<'PY'
import json, os, sys, tempfile
from pathlib import Path
path = Path(sys.argv[1])
data = {
"schema_version": 1,
"source_digest": sys.argv[2],
"current_release": sys.argv[3],
"previous_release": sys.argv[4] or None,
"last_rollback": None,
}
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(prefix=".install-receipt-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temp, 0o600)
os.replace(temp, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try:
os.unlink(temp)
except FileNotFoundError:
pass
PY
chown aios-orchestrator:aios-orchestrator "$STATE_ROOT/install-receipt.json"
chmod 0600 "$STATE_ROOT/install-receipt.json"
systemctl daemon-reload
systemctl enable --now aios-orchestrator-gateway.service
systemctl enable --now aios-orchestrator-gateway-poll.timer
systemctl is-active --quiet aios-orchestrator-gateway.service
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
PYTHONPATH="$INSTALL_ROOT/current" \
python3 "$INSTALL_ROOT/current/scripts/bootstrap_orchestration_loop.py" \
--config "$CONFIG_ROOT/config.json"
systemctl start aios-orchestrator-gateway-poll.service
printf 'AIOS_ORCHESTRATOR_INSTALL=PASS digest=%s previous=%s\n' \
"$SOURCE_DIGEST" "${PREVIOUS_TARGET:-NONE}"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Submit an OpenClaw/Codex task lifecycle event over HTTP or a polled inbox."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.request
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
from services.aios_orchestrator_gateway.models import Event
from services.aios_orchestrator_gateway.securefs import atomic_write_bytes_shared, read_secure_json, read_secure_text
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser()
p.add_argument("--project-key", required=True)
p.add_argument("--contract-commit", required=True)
p.add_argument("--event-id", required=True)
p.add_argument("--source-agent", required=True, choices=["openclaw", "codex", "gitea", "system"])
p.add_argument("--event-type", required=True)
p.add_argument("--work-unit-id", required=True)
p.add_argument(
"--status",
required=True,
choices=["READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"],
)
p.add_argument("--state-version", type=int, required=True)
p.add_argument("--implementation-commit")
p.add_argument("--result-commit")
p.add_argument("--blocker")
p.add_argument("--evidence-ref", action="append", default=[])
p.add_argument("--payload-file", type=Path)
p.add_argument("--endpoint")
p.add_argument("--token-file", type=Path)
p.add_argument("--inbox-dir", type=Path)
p.add_argument("--timeout-seconds", type=int, default=15)
return p
def main() -> int:
args = parser().parse_args()
if not args.endpoint and not args.inbox_dir:
raise SystemExit("either --endpoint or --inbox-dir is required")
if args.endpoint and not args.token_file:
raise SystemExit("--token-file is required with --endpoint")
payload = {}
if args.payload_file:
if not args.payload_file.is_absolute():
raise SystemExit("--payload-file must be absolute")
payload = read_secure_json(
args.payload_file, expected_uid=os.getuid(), max_bytes=262_144
)
if not isinstance(payload, dict):
raise SystemExit("payload root must be an object")
event = Event.from_dict(
{
"schema_version": 1,
"project_key": args.project_key,
"contract_commit": args.contract_commit,
"event_id": args.event_id,
"source_agent": args.source_agent,
"event_type": args.event_type,
"work_unit_id": args.work_unit_id,
"status": args.status,
"state_version": args.state_version,
"implementation_commit": args.implementation_commit,
"result_commit": args.result_commit,
"blocker": args.blocker,
"evidence_refs": args.evidence_ref,
"payload": payload,
}
)
encoded = event.canonical_json().encode()
http_error: Exception | None = None
if args.endpoint:
token = read_secure_text(
args.token_file,
expected_uid=None,
max_bytes=64_000,
)
request = urllib.request.Request(
args.endpoint.rstrip("/") + "/v1/orchestrator/events",
data=encoded,
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Idempotency-Key": event.event_id,
"User-Agent": "aios-agent-event-reporter/0.2",
},
)
try:
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
response.read(65_536)
if response.status not in {200, 202}:
raise RuntimeError(f"unexpected HTTP {response.status}")
print(f"EVENT_SUBMIT=PASS event_id={event.event_id} channel=http")
return 0
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
http_error = exc
if args.inbox_dir:
pending = args.inbox_dir / "pending"
path = pending / f"{event.event_id}.json"
atomic_write_bytes_shared(
path,
(json.dumps(event.to_dict(), indent=2, sort_keys=True) + "\n").encode(),
mode=0o640,
group_writable_directory=True,
)
print(f"EVENT_SUBMIT=PASS event_id={event.event_id} channel=file_inbox")
return 0
print(
f"EVENT_SUBMIT=FAIL event_id={event.event_id} reason={type(http_error).__name__}",
file=sys.stderr,
)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0077
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
RECEIPT="$STATE_ROOT/install-receipt.json"
fail() {
printf 'AIOS_ORCHESTRATOR_ROLLBACK=BLOCKED reason=%s\n' "$1" >&2
exit 2
}
[[ "$(id -u)" -eq 0 ]] || fail OWNER_REQUIRED
[[ -f "$RECEIPT" ]] || fail RECEIPT_MISSING
[[ ! -L "$RECEIPT" ]] || fail RECEIPT_SYMLINK
[[ "$(stat -c '%u:%a' "$RECEIPT")" == "0:600" ]] || fail RECEIPT_PERMISSION
readarray -t VALUES < <(
python3 - "$RECEIPT" "$INSTALL_ROOT/releases" "$INSTALL_ROOT/current" <<'PY'
import json, os, stat, sys
from pathlib import Path
receipt = Path(sys.argv[1])
releases = Path(sys.argv[2]).resolve()
current_link = Path(sys.argv[3])
data = json.loads(receipt.read_text())
previous = data.get("previous_release")
current = data.get("current_release")
if not previous or not current:
raise SystemExit(2)
previous_path = Path(previous).resolve()
current_path = Path(current).resolve()
if previous_path.parent != releases or current_path.parent != releases:
raise SystemExit(2)
if not stat.S_ISDIR(os.lstat(previous_path).st_mode) or not stat.S_ISDIR(os.lstat(current_path).st_mode):
raise SystemExit(2)
for release in (previous_path, current_path):
if len(release.name) != 64 or any(ch not in "0123456789abcdef" for ch in release.name):
raise SystemExit(2)
digest_file = release / "SOURCE_DIGEST"
if digest_file.is_symlink() or not digest_file.is_file():
raise SystemExit(2)
if digest_file.read_text(encoding="utf-8").strip() != release.name:
raise SystemExit(2)
last = data.get("last_rollback")
if isinstance(last, dict) and last.get("to") == current and current_link.is_symlink():
if current_link.resolve() == current_path:
print("ALREADY_ROLLED_BACK")
raise SystemExit(0)
print(previous_path)
print(current_path)
PY
) || fail RECEIPT_INVALID
if [[ "${VALUES[0]:-}" == "ALREADY_ROLLED_BACK" ]]; then
echo "AIOS_ORCHESTRATOR_ROLLBACK=PASS result=NOOP_ALREADY_ROLLED_BACK"
exit 0
fi
[[ "${#VALUES[@]}" -eq 2 ]] || fail RECEIPT_INVALID
PREVIOUS="${VALUES[0]}"
CURRENT="${VALUES[1]}"
systemctl stop aios-orchestrator-gateway-poll.timer || true
systemctl stop aios-orchestrator-gateway-poll.service || true
systemctl stop aios-orchestrator-gateway.service || true
ln -s "$PREVIOUS" "$INSTALL_ROOT/.current-rollback-$$"
mv -Tf "$INSTALL_ROOT/.current-rollback-$$" "$INSTALL_ROOT/current"
systemctl start aios-orchestrator-gateway.service
systemctl is-active --quiet aios-orchestrator-gateway.service
systemctl start aios-orchestrator-gateway-poll.timer
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
python3 - "$RECEIPT" "$PREVIOUS" "$CURRENT" <<'PY'
import json, os, sys, tempfile
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text())
data["current_release"] = sys.argv[2]
data["previous_release"] = sys.argv[3]
data["last_rollback"] = {"from": sys.argv[3], "to": sys.argv[2]}
fd, temp = tempfile.mkstemp(prefix=".rollback-receipt-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
json.dump(data, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(temp, 0o600)
os.chown(temp, 0, 0)
os.replace(temp, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
try:
os.unlink(temp)
except FileNotFoundError:
pass
PY
printf 'AIOS_ORCHESTRATOR_ROLLBACK=PASS current=%s previous=%s\n' "$PREVIOUS" "$CURRENT"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CONFIG="${1:-/etc/aios-orchestrator-gateway/config.json}"
PORT="${PORT:-18920}"
python3 -m json.tool "$CONFIG" >/dev/null
systemd-analyze verify \
/etc/systemd/system/aios-orchestrator-gateway.service \
/etc/systemd/system/aios-orchestrator-gateway-poll.service \
/etc/systemd/system/aios-orchestrator-gateway-poll.timer
systemctl is-active --quiet aios-orchestrator-gateway.service
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
health="$(curl --fail --silent --show-error "http://127.0.0.1:${PORT}/healthz")"
python3 -c '
import json, sys
v=json.load(sys.stdin)
assert v["service"]=="aios-orchestrator-gateway"
assert v["status"]=="active"
assert v["polling"] is True
assert len(v["contract_commit"])==40
' <<<"$health"
systemctl start aios-orchestrator-gateway-poll.service
if journalctl -u aios-orchestrator-gateway.service -n 200 --no-pager \
| grep -Eqi 'Bearer |token=|api[_-]?key|secret='; then
echo "AIOS_ORCHESTRATOR_VERIFY=BLOCKED reason=SECRET_PATTERN_IN_LOG" >&2
exit 2
fi
echo "AIOS_ORCHESTRATOR_VERIFY=PASS"
+1
View File
@@ -0,0 +1 @@
"""AIOS services namespace."""
@@ -0,0 +1,3 @@
"""Reliable central orchestration gateway for AIOS."""
__version__ = "0.2.0"
@@ -0,0 +1,34 @@
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from .config import Config
from .service import Runtime, serve
def main() -> int:
parser = argparse.ArgumentParser(description="AIOS Orchestrator Gateway")
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--log-level", default="INFO")
parser.add_argument(
"--poll-once",
action="store_true",
help="Run one inbox/model/outbox/OpenClaw synchronization polling cycle.",
)
args = parser.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
if args.poll_once:
runtime = Runtime(Config.from_file(args.config))
runtime.poll_once()
return 0
serve(args.config)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,178 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import Any
from .errors import TriggerError
from .models import DECISION_SCHEMA, Decision, Event
class WorkspaceAgentClient:
"""Asynchronous Workspace Agent trigger.
The official endpoint returns only 202 and no run ID or retrievable output.
Completion therefore arrives through the gateway callback endpoint.
"""
def __init__(self, trigger_id: str, token: str, timeout_seconds: int = 30):
self.trigger_id = trigger_id
self.token = token
self.timeout_seconds = timeout_seconds
def trigger(self, event: Event) -> None:
url = f"https://api.chatgpt.com/v1/workspace_agents/{self.trigger_id}/trigger"
prompt = (
"Process this AIOS orchestration event under the committed contract. "
"When complete, call submit_orchestration_decision with strict JSON. "
"Do not fabricate tests, Git state, secrets, evidence, or deployment facts.\n\n"
+ event.canonical_json()
)
body = json.dumps(
{
"conversation_key": event.project_key,
"input": prompt,
}
).encode()
request = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"Idempotency-Key": event.event_id,
"User-Agent": "aios-orchestrator-gateway/0.2",
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
response.read(1024)
if response.status != 202:
raise TriggerError(f"workspace_agent_http_{response.status}")
except urllib.error.HTTPError as exc:
raise TriggerError(f"workspace_agent_http_{exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise TriggerError("workspace_agent_trigger_failed") from exc
class ResponsesClient:
"""Background Responses API client with polling and strict output validation."""
def __init__(self, api_key: str, model: str, timeout_seconds: int = 60):
self.api_key = api_key
self.model = model
self.timeout_seconds = timeout_seconds
def start(self, event: Event) -> str:
body = {
"model": self.model,
"background": True,
"instructions": (
"You are the AIOS central orchestration brain. Apply the exact "
"contract_commit in the event. Return only a strict decision. "
"Never fabricate evidence, secrets, Git facts, tests, approvals, "
"or deployment state. Keep external blockers fail-closed."
),
"input": event.canonical_json(),
"text": {
"format": {
"type": "json_schema",
"name": "aios_orchestrator_decision",
"strict": True,
"schema": DECISION_SCHEMA,
}
},
"metadata": {
"event_id": event.event_id,
"project_key": event.project_key,
"contract_commit": event.contract_commit,
},
}
payload = self._request(
"POST",
"https://api.openai.com/v1/responses",
body,
idempotency_key=event.event_id,
)
response_id = payload.get("id")
if not isinstance(response_id, str) or not response_id.startswith("resp_"):
raise TriggerError("responses_missing_id")
return response_id
def poll(self, response_id: str) -> tuple[str, Decision | None]:
payload = self._request(
"GET",
f"https://api.openai.com/v1/responses/{response_id}",
None,
)
status = payload.get("status")
if status in {"queued", "in_progress"}:
return str(status), None
if status == "completed":
text = _extract_output_text(payload)
try:
return "completed", Decision.from_dict(json.loads(text))
except json.JSONDecodeError as exc:
raise TriggerError("responses_invalid_json") from exc
if status in {"failed", "cancelled", "incomplete"}:
raise TriggerError(f"responses_{status}")
raise TriggerError("responses_unknown_status")
def _request(
self,
method: str,
url: str,
body: dict[str, Any] | None,
*,
idempotency_key: str | None = None,
) -> dict[str, Any]:
encoded = json.dumps(body).encode() if body is not None else None
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"User-Agent": "aios-orchestrator-gateway/0.2",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(
url,
data=encoded,
method=method,
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
raw = response.read(4_194_304)
if response.status not in {200, 201}:
raise TriggerError(f"responses_http_{response.status}")
except urllib.error.HTTPError as exc:
raise TriggerError(f"responses_http_{exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise TriggerError("responses_request_failed") from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise TriggerError("responses_invalid_payload") from exc
if not isinstance(payload, dict):
raise TriggerError("responses_payload_not_object")
return payload
def _extract_output_text(payload: dict[str, Any]) -> str:
output = payload.get("output")
if not isinstance(output, list):
raise TriggerError("responses_missing_output")
for item in output:
if not isinstance(item, dict):
continue
content = item.get("content")
if not isinstance(content, list):
continue
for part in content:
if isinstance(part, dict) and part.get("type") == "output_text":
text = part.get("text")
if isinstance(text, str):
return text
raise TriggerError("responses_missing_output_text")
@@ -0,0 +1,203 @@
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from typing import Any
from .errors import ConfigurationError
from .securefs import read_secure_json, read_secure_text
@dataclass(frozen=True)
class Config:
host: str
port: int
project_key: str
database_path: Path
event_inbox_dir: Path
decision_inbox_dir: Path
dispatch_outbox_dir: Path
openclaw_sync_dir: Path
command_queue_dir: Path
inbound_token_file: Path
callback_token_file: Path
contract_commit: str
workspace_trigger_id: str | None
workspace_token_file: Path | None
responses_api_key_file: Path | None
responses_model: str | None
poll_interval_seconds: int
summary_interval_seconds: int
callback_timeout_seconds: int
lease_seconds: int
retry_base_seconds: int
max_retries: int
max_same_blocker_retries: int
max_hops_per_gate: int
openclaw_sync_endpoint: str | None
openclaw_sync_token_file: Path | None
git_poll_enabled: bool
git_poll_bare_repo: Path | None
git_poll_ref_prefixes: tuple[str, ...]
git_poll_interval_seconds: int
@classmethod
def from_file(cls, path: Path) -> "Config":
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_CONFIG_OWNER_UID", "0"))
raw = read_secure_json(path, expected_uid=owner_uid)
expected = {
"host", "port", "project_key", "database_path", "event_inbox_dir",
"decision_inbox_dir", "dispatch_outbox_dir", "openclaw_sync_dir",
"command_queue_dir", "inbound_token_file", "callback_token_file",
"contract_commit", "workspace_agent", "responses_fallback",
"polling", "loop_controls", "openclaw_sync", "git_poll",
}
if set(raw) != expected:
raise ConfigurationError("invalid_config_fields")
def path_value(key: str) -> Path:
value = raw.get(key)
if not isinstance(value, str):
raise ConfigurationError(f"invalid_{key}")
result = Path(value)
if not result.is_absolute():
raise ConfigurationError(f"path_not_absolute:{key}")
return result
def integer(container: dict[str, Any], key: str, lo: int, hi: int) -> int:
value = container.get(key)
if not isinstance(value, int) or not lo <= value <= hi:
raise ConfigurationError(f"invalid_{key}")
return value
host = raw.get("host")
port = raw.get("port")
project_key = raw.get("project_key")
contract = raw.get("contract_commit")
if not isinstance(host, str) or not host:
raise ConfigurationError("invalid_host")
if not isinstance(project_key, str) or not project_key:
raise ConfigurationError("invalid_project_key")
if not isinstance(port, int) or not 1 <= port <= 65535:
raise ConfigurationError("invalid_port")
if not isinstance(contract, str) or len(contract) != 40:
raise ConfigurationError("invalid_contract_commit")
workspace = raw.get("workspace_agent")
responses = raw.get("responses_fallback")
polling = raw.get("polling")
loop = raw.get("loop_controls")
sync = raw.get("openclaw_sync")
git_poll = raw.get("git_poll")
if not all(isinstance(x, dict) for x in (workspace, responses, polling, loop, sync, git_poll)):
raise ConfigurationError("invalid_nested_config")
trigger_id = workspace.get("trigger_id")
workspace_token = workspace.get("access_token_file")
if trigger_id is not None:
if not isinstance(trigger_id, str) or not trigger_id.startswith("agtch_"):
raise ConfigurationError("invalid_workspace_trigger_id")
if not isinstance(workspace_token, str) or not Path(workspace_token).is_absolute():
raise ConfigurationError("invalid_workspace_token_file")
workspace_token_file = Path(workspace_token)
else:
workspace_token_file = None
responses_key = responses.get("api_key_file")
responses_model = responses.get("model")
if responses_key is not None:
if not isinstance(responses_key, str) or not Path(responses_key).is_absolute():
raise ConfigurationError("invalid_responses_key_file")
if not isinstance(responses_model, str) or not responses_model:
raise ConfigurationError("invalid_responses_model")
responses_key_file = Path(responses_key)
else:
responses_key_file = None
responses_model = None
if trigger_id is None and responses_key_file is None:
raise ConfigurationError("no_central_model_channel")
endpoint = sync.get("endpoint")
token_file = sync.get("token_file")
if endpoint is not None and (not isinstance(endpoint, str) or not endpoint.startswith(("http://", "https://"))):
raise ConfigurationError("invalid_openclaw_sync_endpoint")
if endpoint is not None:
if not isinstance(token_file, str) or not Path(token_file).is_absolute():
raise ConfigurationError("invalid_openclaw_sync_token_file")
sync_token_file = Path(token_file)
else:
sync_token_file = None
git_enabled = git_poll.get("enabled")
if not isinstance(git_enabled, bool):
raise ConfigurationError("invalid_git_poll_enabled")
git_bare_repo_raw = git_poll.get("bare_repo")
git_prefixes_raw = git_poll.get("ref_prefixes")
if git_enabled:
if not isinstance(git_bare_repo_raw, str) or not Path(git_bare_repo_raw).is_absolute():
raise ConfigurationError("invalid_git_poll_bare_repo")
if (
not isinstance(git_prefixes_raw, list)
or not git_prefixes_raw
or any(not isinstance(item, str) or not item.startswith("refs/") for item in git_prefixes_raw)
):
raise ConfigurationError("invalid_git_poll_ref_prefixes")
git_bare_repo = Path(git_bare_repo_raw)
git_prefixes = tuple(git_prefixes_raw)
else:
git_bare_repo = None
git_prefixes = ()
return cls(
host=host,
port=port,
project_key=project_key,
database_path=path_value("database_path"),
event_inbox_dir=path_value("event_inbox_dir"),
decision_inbox_dir=path_value("decision_inbox_dir"),
dispatch_outbox_dir=path_value("dispatch_outbox_dir"),
openclaw_sync_dir=path_value("openclaw_sync_dir"),
command_queue_dir=path_value("command_queue_dir"),
inbound_token_file=path_value("inbound_token_file"),
callback_token_file=path_value("callback_token_file"),
contract_commit=contract,
workspace_trigger_id=trigger_id,
workspace_token_file=workspace_token_file,
responses_api_key_file=responses_key_file,
responses_model=responses_model,
poll_interval_seconds=integer(polling, "interval_seconds", 1, 300),
summary_interval_seconds=integer(polling, "summary_interval_seconds", 10, 86400),
callback_timeout_seconds=integer(loop, "callback_timeout_seconds", 30, 86400),
lease_seconds=integer(loop, "lease_seconds", 10, 3600),
retry_base_seconds=integer(loop, "retry_base_seconds", 1, 3600),
max_retries=integer(loop, "max_retries", 0, 20),
max_same_blocker_retries=integer(loop, "max_same_blocker_retries", 0, 20),
max_hops_per_gate=integer(loop, "max_hops_per_gate", 1, 100),
openclaw_sync_endpoint=endpoint,
openclaw_sync_token_file=sync_token_file,
git_poll_enabled=git_enabled,
git_poll_bare_repo=git_bare_repo,
git_poll_ref_prefixes=git_prefixes,
git_poll_interval_seconds=integer(git_poll, "interval_seconds", 5, 3600),
)
def _secret(self, path: Path) -> str:
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_SECRET_OWNER_UID", "0"))
return read_secure_text(path, expected_uid=owner_uid, max_bytes=64_000)
def inbound_token(self) -> str:
return self._secret(self.inbound_token_file)
def callback_token(self) -> str:
return self._secret(self.callback_token_file)
def workspace_token(self) -> str | None:
return self._secret(self.workspace_token_file) if self.workspace_token_file else None
def responses_api_key(self) -> str | None:
return self._secret(self.responses_api_key_file) if self.responses_api_key_file else None
def openclaw_sync_token(self) -> str | None:
return self._secret(self.openclaw_sync_token_file) if self.openclaw_sync_token_file else None
@@ -0,0 +1,89 @@
from __future__ import annotations
import json
from pathlib import Path
import re
import os
import stat
from typing import Any
from .errors import DispatchError
from .securefs import (
atomic_write_bytes,
atomic_write_bytes_shared,
ensure_secure_directory,
ensure_shared_directory,
read_secure_bytes,
)
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
class DispatchSink:
"""Reliable file-based dispatch adapter for existing OpenClaw/Codex watchers."""
def __init__(self, outbox_dir: Path, command_queue_dir: Path, contract_commit: str):
self.outbox_dir = Path(outbox_dir)
self.command_queue_dir = Path(command_queue_dir)
self.contract_commit = contract_commit
ensure_secure_directory(self.outbox_dir)
ensure_shared_directory(self.command_queue_dir)
def dispatch(self, record: dict[str, Any]) -> tuple[Path, Path]:
work_unit_id = record.get("work_unit_id")
event_id = record.get("event_id")
agent = record.get("assigned_agent")
payload_json = record.get("payload_json")
if (
not isinstance(work_unit_id, str)
or not SAFE_ID.fullmatch(work_unit_id)
or not isinstance(event_id, str)
or not SAFE_ID.fullmatch(event_id)
or agent not in {"openclaw", "codex"}
or not isinstance(payload_json, str)
):
raise DispatchError("invalid_dispatch_record")
try:
work_unit = json.loads(payload_json)
except json.JSONDecodeError as exc:
raise DispatchError("invalid_dispatch_payload") from exc
envelope = {
"schema_version": 1,
"contract_commit": self.contract_commit,
"event_id": event_id,
"assigned_agent": agent,
"work_unit": work_unit,
"callback": {
"event_endpoint": "/v1/orchestrator/events",
"start_event_type": "work_unit_started",
"end_event_type": "work_unit_completed",
},
}
outbox_path = self.outbox_dir / "dispatched" / f"{work_unit_id}.json"
queue_path = self.command_queue_dir / agent / "pending" / f"{work_unit_id}.json"
ensure_secure_directory(outbox_path.parent)
ensure_shared_directory(queue_path.parent)
# Idempotent immutable payload: existing identical file is accepted.
self._write_idempotent_json(outbox_path, envelope, shared=False)
self._write_idempotent_json(queue_path, envelope, shared=True)
return outbox_path, queue_path
@staticmethod
def _write_idempotent_json(path: Path, value: dict[str, Any], *, shared: bool) -> None:
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
try:
st = os.lstat(path)
except FileNotFoundError:
if shared:
atomic_write_bytes_shared(path, payload, mode=0o640)
else:
atomic_write_bytes(path, payload, mode=0o600)
return
if not stat.S_ISREG(st.st_mode):
raise DispatchError(f"dispatch_target_not_regular:{path.name}")
existing = read_secure_bytes(path, expected_uid=None, max_bytes=1_048_576)
if existing != payload:
raise DispatchError(f"dispatch_idempotency_conflict:{path.name}")
@@ -0,0 +1,26 @@
class OrchestratorError(Exception):
"""Base orchestration error."""
class ConfigurationError(OrchestratorError):
"""Unsafe or invalid configuration."""
class ValidationError(OrchestratorError):
"""Invalid event or decision."""
class AuthenticationError(OrchestratorError):
"""Authentication failed."""
class ConflictError(OrchestratorError):
"""Idempotency, lease, or optimistic concurrency conflict."""
class TriggerError(OrchestratorError):
"""Central model trigger failed."""
class DispatchError(OrchestratorError):
"""Dispatch or synchronization output failed."""
@@ -0,0 +1,346 @@
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
import json
import re
from typing import Any
from .errors import ValidationError
OID_RE = re.compile(r"^[0-9a-f]{40}$")
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
AGENTS = {"openclaw", "codex", "gitea", "system", "workspace-agent"}
EVENT_STATUSES = {"READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"}
VERDICTS = {"DISPATCH", "BLOCKED", "COMPLETE", "NOOP"}
EXECUTORS = {"openclaw", "codex"}
def req_str(value: dict[str, Any], key: str, pattern: re.Pattern[str] | None = None) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
raise ValidationError(f"invalid_{key}")
if pattern is not None and not pattern.fullmatch(item):
raise ValidationError(f"invalid_{key}")
return item
def opt_oid(item: Any, key: str) -> str | None:
if item is None:
return None
if not isinstance(item, str) or not OID_RE.fullmatch(item):
raise ValidationError(f"invalid_{key}")
return item
def string_list(value: dict[str, Any], key: str, limit: int) -> tuple[str, ...]:
raw = value.get(key)
if not isinstance(raw, list) or len(raw) > limit:
raise ValidationError(f"invalid_{key}")
result: list[str] = []
for item in raw:
if not isinstance(item, str) or not item or len(item) > 1024:
raise ValidationError(f"invalid_{key}")
result.append(item)
return tuple(result)
@dataclass(frozen=True)
class Event:
schema_version: int
project_key: str
contract_commit: str
event_id: str
source_agent: str
event_type: str
work_unit_id: str
status: str
state_version: int
implementation_commit: str | None = None
result_commit: str | None = None
blocker: str | None = None
evidence_refs: tuple[str, ...] = ()
payload: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "Event":
allowed = {
"schema_version", "project_key", "contract_commit", "event_id",
"source_agent", "event_type", "work_unit_id", "status",
"state_version", "implementation_commit", "result_commit",
"blocker", "evidence_refs", "payload",
}
if not isinstance(value, dict) or set(value) - allowed:
raise ValidationError("invalid_event_fields")
if value.get("schema_version") != 1:
raise ValidationError("unsupported_event_schema")
source = req_str(value, "source_agent")
status = req_str(value, "status")
if source not in AGENTS:
raise ValidationError("invalid_source_agent")
if status not in EVENT_STATUSES:
raise ValidationError("invalid_event_status")
version = value.get("state_version")
if not isinstance(version, int) or version < 0:
raise ValidationError("invalid_state_version")
blocker = value.get("blocker")
if blocker is not None and (not isinstance(blocker, str) or len(blocker) > 512):
raise ValidationError("invalid_blocker")
payload = value.get("payload", {})
if not isinstance(payload, dict):
raise ValidationError("invalid_payload")
if len(json.dumps(payload, sort_keys=True).encode()) > 262_144:
raise ValidationError("payload_too_large")
return cls(
schema_version=1,
project_key=req_str(value, "project_key", ID_RE),
contract_commit=req_str(value, "contract_commit", OID_RE),
event_id=req_str(value, "event_id", ID_RE),
source_agent=source,
event_type=req_str(value, "event_type", ID_RE),
work_unit_id=req_str(value, "work_unit_id", ID_RE),
status=status,
state_version=version,
implementation_commit=opt_oid(value.get("implementation_commit"), "implementation_commit"),
result_commit=opt_oid(value.get("result_commit"), "result_commit"),
blocker=blocker,
evidence_refs=string_list(value, "evidence_refs", 64),
payload=payload,
)
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": 1,
"project_key": self.project_key,
"contract_commit": self.contract_commit,
"event_id": self.event_id,
"source_agent": self.source_agent,
"event_type": self.event_type,
"work_unit_id": self.work_unit_id,
"status": self.status,
"state_version": self.state_version,
"implementation_commit": self.implementation_commit,
"result_commit": self.result_commit,
"blocker": self.blocker,
"evidence_refs": list(self.evidence_refs),
"payload": self.payload,
}
def canonical_json(self) -> str:
return json.dumps(self.to_dict(), separators=(",", ":"), sort_keys=True)
def digest(self) -> str:
return hashlib.sha256(self.canonical_json().encode()).hexdigest()
@dataclass(frozen=True)
class WorkUnit:
id: str
track_id: str
gate_id: str
primary_agent: str
fallback_agent: str
clean_base_commit: str
allowed_paths: tuple[str, ...]
required_checks: tuple[str, ...]
forbidden_operations: tuple[str, ...]
instruction: str
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "WorkUnit":
expected = {
"id", "track_id", "gate_id", "primary_agent", "fallback_agent",
"clean_base_commit", "allowed_paths", "required_checks",
"forbidden_operations", "instruction",
}
if not isinstance(value, dict) or set(value) != expected:
raise ValidationError("invalid_work_unit_fields")
primary = req_str(value, "primary_agent")
fallback = req_str(value, "fallback_agent")
if primary not in EXECUTORS or fallback not in EXECUTORS or primary == fallback:
raise ValidationError("invalid_agent_assignment")
instruction = req_str(value, "instruction")
if len(instruction.encode()) > 65_536:
raise ValidationError("instruction_too_large")
return cls(
id=req_str(value, "id", ID_RE),
track_id=req_str(value, "track_id", ID_RE),
gate_id=req_str(value, "gate_id", ID_RE),
primary_agent=primary,
fallback_agent=fallback,
clean_base_commit=req_str(value, "clean_base_commit", OID_RE),
allowed_paths=string_list(value, "allowed_paths", 128),
required_checks=string_list(value, "required_checks", 128),
forbidden_operations=string_list(value, "forbidden_operations", 64),
instruction=instruction,
)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"track_id": self.track_id,
"gate_id": self.gate_id,
"primary_agent": self.primary_agent,
"fallback_agent": self.fallback_agent,
"clean_base_commit": self.clean_base_commit,
"allowed_paths": list(self.allowed_paths),
"required_checks": list(self.required_checks),
"forbidden_operations": list(self.forbidden_operations),
"instruction": self.instruction,
}
@dataclass(frozen=True)
class Decision:
schema_version: int
project_key: str
contract_commit: str
event_id: str
expected_state_version: int
verdict: str
state_updates: dict[str, str]
next_work_units: tuple[WorkUnit, ...]
requires_human_input: bool
stop_reason: str | None
@classmethod
def from_dict(cls, value: dict[str, Any]) -> "Decision":
expected = {
"schema_version", "project_key", "contract_commit", "event_id",
"expected_state_version", "verdict", "state_updates",
"next_work_units", "requires_human_input", "stop_reason",
}
if not isinstance(value, dict) or set(value) != expected:
raise ValidationError("invalid_decision_fields")
if value.get("schema_version") != 1:
raise ValidationError("unsupported_decision_schema")
version = value.get("expected_state_version")
if not isinstance(version, int) or version < 0:
raise ValidationError("invalid_expected_state_version")
verdict = req_str(value, "verdict")
if verdict not in VERDICTS:
raise ValidationError("invalid_verdict")
updates = value.get("state_updates")
normalized: dict[str, str] = {}
if isinstance(updates, dict):
update_items = [{"key": key, "value": item} for key, item in updates.items()]
elif isinstance(updates, list):
update_items = updates
else:
raise ValidationError("invalid_state_updates")
if len(update_items) > 128:
raise ValidationError("invalid_state_updates")
for update in update_items:
if not isinstance(update, dict) or set(update) != {"key", "value"}:
raise ValidationError("invalid_state_update")
key = update.get("key")
item = update.get("value")
if not isinstance(key, str) or not ID_RE.fullmatch(key):
raise ValidationError("invalid_state_update_key")
if not isinstance(item, str) or not item or len(item) > 256:
raise ValidationError("invalid_state_update_value")
if key in normalized:
raise ValidationError("duplicate_state_update_key")
normalized[key] = item
raw_units = value.get("next_work_units")
if not isinstance(raw_units, list) or len(raw_units) > 32:
raise ValidationError("invalid_next_work_units")
units = tuple(WorkUnit.from_dict(item) for item in raw_units)
human = value.get("requires_human_input")
if not isinstance(human, bool):
raise ValidationError("invalid_requires_human_input")
stop = value.get("stop_reason")
if stop is not None and (not isinstance(stop, str) or len(stop) > 512):
raise ValidationError("invalid_stop_reason")
if verdict == "DISPATCH" and not units:
raise ValidationError("dispatch_without_work_units")
if verdict != "DISPATCH" and units:
raise ValidationError("work_units_without_dispatch")
return cls(
schema_version=1,
project_key=req_str(value, "project_key", ID_RE),
contract_commit=req_str(value, "contract_commit", OID_RE),
event_id=req_str(value, "event_id", ID_RE),
expected_state_version=version,
verdict=verdict,
state_updates=normalized,
next_work_units=units,
requires_human_input=human,
stop_reason=stop,
)
def to_dict(self) -> dict[str, Any]:
return {
"schema_version": 1,
"project_key": self.project_key,
"contract_commit": self.contract_commit,
"event_id": self.event_id,
"expected_state_version": self.expected_state_version,
"verdict": self.verdict,
"state_updates": [
{"key": key, "value": value}
for key, value in sorted(self.state_updates.items())
],
"next_work_units": [u.to_dict() for u in self.next_work_units],
"requires_human_input": self.requires_human_input,
"stop_reason": self.stop_reason,
}
DECISION_SCHEMA: dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"required": [
"schema_version", "project_key", "contract_commit", "event_id",
"expected_state_version", "verdict", "state_updates",
"next_work_units", "requires_human_input", "stop_reason",
],
"properties": {
"schema_version": {"type": "integer", "const": 1},
"project_key": {"type": "string"},
"contract_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"event_id": {"type": "string"},
"expected_state_version": {"type": "integer", "minimum": 0},
"verdict": {"type": "string", "enum": sorted(VERDICTS)},
"state_updates": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["key", "value"],
"properties": {
"key": {"type": "string"},
"value": {"type": "string"}
}
}
},
"next_work_units": {
"type": "array",
"maxItems": 32,
"items": {
"type": "object",
"additionalProperties": False,
"required": [
"id", "track_id", "gate_id", "primary_agent",
"fallback_agent", "clean_base_commit", "allowed_paths",
"required_checks", "forbidden_operations", "instruction",
],
"properties": {
"id": {"type": "string"},
"track_id": {"type": "string"},
"gate_id": {"type": "string"},
"primary_agent": {"type": "string", "enum": ["openclaw", "codex"]},
"fallback_agent": {"type": "string", "enum": ["openclaw", "codex"]},
"clean_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
"allowed_paths": {"type": "array", "items": {"type": "string"}},
"required_checks": {"type": "array", "items": {"type": "string"}},
"forbidden_operations": {"type": "array", "items": {"type": "string"}},
"instruction": {"type": "string"},
},
},
},
"requires_human_input": {"type": "boolean"},
"stop_reason": {"type": ["string", "null"]},
},
}
@@ -0,0 +1,229 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
from typing import Any
from .errors import ConfigurationError
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
def reject_symlink_chain(path: Path) -> None:
"""Reject symlinks in every existing component of an absolute path."""
path = Path(path)
if not path.is_absolute():
raise ConfigurationError(f"path_not_absolute:{path}")
current = Path(path.anchor)
for part in path.parts[1:]:
current = current / part
try:
st = os.lstat(current)
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
raise ConfigurationError(f"symlink_rejected:{current}")
def verify_secure_file(
path: Path,
*,
expected_uid: int | None,
allowed_write_bits: int = stat.S_IWUSR,
require_executable: bool = False,
) -> os.stat_result:
path = Path(path)
reject_symlink_chain(path)
before = os.lstat(path)
if not stat.S_ISREG(before.st_mode):
raise ConfigurationError(f"not_regular_file:{path}")
if expected_uid is not None and before.st_uid != expected_uid:
raise ConfigurationError(f"unexpected_owner:{path}")
write_bits = before.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
if write_bits & ~allowed_write_bits:
raise ConfigurationError(f"permissive_mode:{path}")
if require_executable and not (before.st_mode & stat.S_IXUSR):
raise ConfigurationError(f"not_executable:{path}")
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
try:
after = os.fstat(fd)
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
raise ConfigurationError(f"inode_changed:{path}")
finally:
os.close(fd)
return before
def read_secure_bytes(
path: Path,
*,
expected_uid: int | None,
max_bytes: int = 1_048_576,
) -> bytes:
path = Path(path)
before = verify_secure_file(path, expected_uid=expected_uid)
if before.st_size > max_bytes:
raise ConfigurationError(f"file_too_large:{path}")
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
try:
data = bytearray()
while True:
chunk = os.read(fd, min(65536, max_bytes + 1 - len(data)))
if not chunk:
break
data.extend(chunk)
if len(data) > max_bytes:
raise ConfigurationError(f"file_too_large:{path}")
return bytes(data)
finally:
os.close(fd)
def read_secure_text(path: Path, *, expected_uid: int | None, max_bytes: int = 1_048_576) -> str:
try:
return read_secure_bytes(path, expected_uid=expected_uid, max_bytes=max_bytes).decode("utf-8").strip()
except UnicodeDecodeError as exc:
raise ConfigurationError(f"not_utf8:{path}") from exc
def read_secure_json(path: Path, *, expected_uid: int | None, max_bytes: int = 1_048_576) -> dict[str, Any]:
try:
value = json.loads(read_secure_text(path, expected_uid=expected_uid, max_bytes=max_bytes))
except json.JSONDecodeError as exc:
raise ConfigurationError(f"invalid_json:{path}") from exc
if not isinstance(value, dict):
raise ConfigurationError(f"json_root_not_object:{path}")
return value
def ensure_secure_directory(path: Path, *, mode: int = 0o700) -> None:
path = Path(path)
if not path.is_absolute():
raise ConfigurationError(f"path_not_absolute:{path}")
reject_symlink_chain(path.parent)
path.mkdir(parents=True, exist_ok=True, mode=mode)
reject_symlink_chain(path)
st = os.lstat(path)
if not stat.S_ISDIR(st.st_mode):
raise ConfigurationError(f"not_directory:{path}")
if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ConfigurationError(f"permissive_directory:{path}")
def atomic_write_bytes(path: Path, payload: bytes, *, mode: int = 0o600) -> None:
"""Write through a verified directory fd and atomically replace the target."""
path = Path(path)
ensure_secure_directory(path.parent)
parent_before = os.lstat(path.parent)
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | _NOFOLLOW)
temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(6).hex()}"
try:
parent_after = os.fstat(parent_fd)
if (parent_before.st_dev, parent_before.st_ino) != (
parent_after.st_dev,
parent_after.st_ino,
):
raise ConfigurationError(f"directory_changed:{path.parent}")
fd = os.open(
temp_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
mode,
dir_fd=parent_fd,
)
try:
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
os.replace(
temp_name,
path.name,
src_dir_fd=parent_fd,
dst_dir_fd=parent_fd,
)
os.fsync(parent_fd)
finally:
try:
os.unlink(temp_name, dir_fd=parent_fd)
except FileNotFoundError:
pass
os.close(parent_fd)
def atomic_write_json(path: Path, value: Any, *, mode: int = 0o600) -> None:
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
atomic_write_bytes(path, payload, mode=mode)
def ensure_shared_directory(path: Path, *, group_writable: bool = False) -> None:
"""Verify/create a setgid queue directory shared with a consumer group."""
path = Path(path)
if not path.is_absolute():
raise ConfigurationError(f"path_not_absolute:{path}")
reject_symlink_chain(path.parent)
mode = 0o2770 if group_writable else 0o2750
try:
path.mkdir(parents=True, mode=mode)
except FileExistsError:
pass
else:
os.chmod(path, mode)
reject_symlink_chain(path)
st = os.lstat(path)
if not stat.S_ISDIR(st.st_mode):
raise ConfigurationError(f"not_directory:{path}")
observed = stat.S_IMODE(st.st_mode)
required = stat.S_ISGID | stat.S_IRGRP | stat.S_IXGRP
if group_writable:
required |= stat.S_IWGRP
if observed & 0o007 or observed & required != required:
raise ConfigurationError(f"invalid_shared_directory_mode:{path}")
def atomic_write_bytes_shared(
path: Path,
payload: bytes,
*,
mode: int = 0o640,
group_writable_directory: bool = False,
) -> None:
"""Atomic write into a setgid/group-readable shared queue directory."""
path = Path(path)
ensure_shared_directory(path.parent, group_writable=group_writable_directory)
parent_before = os.lstat(path.parent)
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | _NOFOLLOW)
temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(6).hex()}"
try:
parent_after = os.fstat(parent_fd)
if (parent_before.st_dev, parent_before.st_ino) != (
parent_after.st_dev,
parent_after.st_ino,
):
raise ConfigurationError(f"directory_changed:{path.parent}")
fd = os.open(
temp_name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
mode,
dir_fd=parent_fd,
)
os.fchmod(fd, mode)
try:
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
os.replace(temp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
os.fsync(parent_fd)
finally:
try:
os.unlink(temp_name, dir_fd=parent_fd)
except FileNotFoundError:
pass
os.close(parent_fd)
@@ -0,0 +1,642 @@
from __future__ import annotations
import hashlib
import hmac
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import logging
import os
from pathlib import Path
import socket
import subprocess
import threading
import time
from typing import Any
from urllib.parse import urlparse
from .clients import ResponsesClient, WorkspaceAgentClient
from .config import Config
from .dispatch import DispatchSink
from .errors import (
AuthenticationError,
ConflictError,
DispatchError,
OrchestratorError,
TriggerError,
ValidationError,
)
from .models import Decision, Event
from .securefs import ensure_secure_directory, ensure_shared_directory, read_secure_json
from .store import Store
from .sync import OpenClawSynchronizer, lifecycle_record
LOG = logging.getLogger("aios-orchestrator-gateway")
class Runtime:
def __init__(self, config: Config):
self.config = config
self.store = Store(config.database_path)
self.dispatch = DispatchSink(
config.dispatch_outbox_dir,
config.command_queue_dir,
config.contract_commit,
)
self.workspace = None
workspace_token = config.workspace_token()
if config.workspace_trigger_id and workspace_token:
self.workspace = WorkspaceAgentClient(
config.workspace_trigger_id,
workspace_token,
)
self.responses = None
responses_key = config.responses_api_key()
if responses_key and config.responses_model:
self.responses = ResponsesClient(
responses_key,
config.responses_model,
)
self.synchronizer = OpenClawSynchronizer(
config.openclaw_sync_dir,
endpoint=config.openclaw_sync_endpoint,
bearer_token=config.openclaw_sync_token(),
)
self.worker_id = f"{socket.gethostname()}-{os.getpid()}-{threading.get_native_id()}"
self.stop_event = threading.Event()
self.worker = threading.Thread(target=self._loop, daemon=True)
self._next_git_poll = 0.0
for directory in (
config.event_inbox_dir,
config.decision_inbox_dir,
config.dispatch_outbox_dir,
config.openclaw_sync_dir,
):
ensure_secure_directory(directory)
ensure_shared_directory(config.command_queue_dir)
def start(self) -> None:
self.worker.start()
def stop(self) -> None:
self.stop_event.set()
if self.worker.is_alive():
self.worker.join(timeout=15)
self.store.close()
def submit_event(self, payload: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
event = Event.from_dict(payload)
if event.contract_commit != self.config.contract_commit:
raise ConflictError("contract_version_mismatch")
created, stored = self.store.register_event(event)
if created:
self._enqueue_event_lifecycle(event)
return created, stored
def submit_decision(self, payload: dict[str, Any]) -> int:
decision = Decision.from_dict(payload)
if decision.contract_commit != self.config.contract_commit:
raise ConflictError("contract_version_mismatch")
version = self.store.apply_decision(
decision,
max_hops_per_gate=self.config.max_hops_per_gate,
max_same_blocker_retries=self.config.max_same_blocker_retries,
)
self._enqueue_decision_lifecycle(decision, version)
return version
def _loop(self) -> None:
next_summary = 0.0
while not self.stop_event.wait(self.config.poll_interval_seconds):
try:
self.poll_once()
if time.monotonic() >= next_summary:
self.enqueue_periodic_summaries()
next_summary = time.monotonic() + self.config.summary_interval_seconds
except Exception:
LOG.exception("poll_cycle_failed")
def poll_once(self) -> None:
"""One deterministic reliability cycle.
This is deliberately callable from a systemd timer or health supervisor
in addition to the internal loop.
"""
self.ingest_file_inboxes()
self.poll_git_refs()
self.process_new_events()
self.process_workspace_timeouts()
self.poll_background_responses()
self.dispatch_work_units()
self.synchronize_openclaw()
def ingest_file_inboxes(self) -> None:
self._ingest_directory(self.config.event_inbox_dir, kind="event")
self._ingest_directory(self.config.decision_inbox_dir, kind="decision")
def _ingest_directory(self, directory: Path, *, kind: str) -> None:
pending = directory / "pending"
processed = directory / "processed"
rejected = directory / "rejected"
ensure_shared_directory(pending, group_writable=True)
ensure_secure_directory(processed)
ensure_secure_directory(rejected)
for path in sorted(pending.glob("*.json"))[:128]:
try:
payload = read_secure_json(path, expected_uid=None)
if kind == "event":
self.submit_event(payload)
else:
self.submit_decision(payload)
target = processed / path.name
except Exception as exc:
LOG.warning("file_inbox_rejected path=%s error=%s", path, type(exc).__name__)
target = rejected / path.name
if target.exists():
# Same file name is an idempotency replay. Keep original audit copy.
path.unlink()
else:
path.replace(target)
def poll_git_refs(self) -> None:
"""Poll authoritative local bare refs so missed webhooks cannot stall the loop."""
if not self.config.git_poll_enabled or self.config.git_poll_bare_repo is None:
return
now = time.monotonic()
if now < self._next_git_poll:
return
self._next_git_poll = now + self.config.git_poll_interval_seconds
command = [
"git",
f"--git-dir={self.config.git_poll_bare_repo}",
"for-each-ref",
"--format=%(refname) %(objectname)",
*self.config.git_poll_ref_prefixes,
]
try:
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
timeout=30,
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
)
except (OSError, subprocess.SubprocessError):
LOG.exception("git_ref_poll_failed")
return
if result.returncode != 0:
LOG.warning("git_ref_poll_rc=%s", result.returncode)
return
refs: dict[str, str] = {}
for line in result.stdout.splitlines():
refname, sep, oid = line.partition(" ")
if not sep or not refname.startswith("refs/") or len(oid) != 40:
continue
if any(ch not in "0123456789abcdef" for ch in oid):
continue
refs[refname] = oid
initialized, changes = self.store.observe_refs(refs)
if not initialized or not changes:
return
payload_changes = [
{"ref": refname, "old_oid": old_oid, "new_oid": new_oid}
for refname, old_oid, new_oid in sorted(changes)
]
canonical = json.dumps(payload_changes, separators=(",", ":"), sort_keys=True)
event_id = "gitpoll-" + hashlib.sha256(canonical.encode()).hexdigest()[:40]
state = self.store.project_state(self.config.project_key)
state_version = 0 if state is None else int(state["state_version"])
event = Event.from_dict(
{
"schema_version": 1,
"project_key": self.config.project_key,
"contract_commit": self.config.contract_commit,
"event_id": event_id,
"source_agent": "gitea",
"event_type": "git_refs_changed",
"work_unit_id": "GIT-REF-POLL",
"status": "READY",
"state_version": state_version,
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {"changes": payload_changes},
}
)
try:
self.submit_event(event.to_dict())
except ConflictError:
LOG.info("git_ref_event_already_registered event_id=%s", event_id)
def process_new_events(self) -> None:
rows = self.store.claim_events(
owner=self.worker_id,
statuses=("RECEIVED", "TRIGGER_FAILED"),
limit=32,
lease_seconds=self.config.lease_seconds,
)
for row in rows:
event = Event.from_dict(json.loads(row["event_json"]))
attempts = int(row["attempts"])
try:
if self.workspace is not None:
self.workspace.trigger(event)
self.store.update_event_trigger(
event.event_id,
status="AWAITING_DECISION",
channel="workspace_agent",
remote_id=None,
)
elif self.responses is not None:
response_id = self.responses.start(event)
self.store.update_event_trigger(
event.event_id,
status="POLLING_RESPONSE",
channel="responses_api",
remote_id=response_id,
retry_after=self.config.poll_interval_seconds,
)
else:
raise TriggerError("no_model_channel")
except TriggerError as exc:
self._retry_or_dead_letter(event, attempts, str(exc))
def process_workspace_timeouts(self) -> None:
cutoff = int(time.time()) - self.config.callback_timeout_seconds
for row in self.store.claim_workspace_timeouts(
owner=self.worker_id,
older_than=cutoff,
limit=32,
lease_seconds=self.config.lease_seconds,
):
event = Event.from_dict(json.loads(row["event_json"]))
if self.responses is None:
self._retry_or_dead_letter(
event,
int(row["attempts"]),
"workspace_callback_timeout",
)
continue
try:
response_id = self.responses.start(event)
self.store.update_event_trigger(
event.event_id,
status="POLLING_RESPONSE",
channel="responses_api",
remote_id=response_id,
retry_after=self.config.poll_interval_seconds,
increment_attempt=True,
)
except TriggerError as exc:
self._retry_or_dead_letter(event, int(row["attempts"]), str(exc))
def poll_background_responses(self) -> None:
if self.responses is None:
return
for row in self.store.claim_response_polls(
owner=self.worker_id,
limit=32,
lease_seconds=self.config.lease_seconds,
):
event = Event.from_dict(json.loads(row["event_json"]))
response_id = row.get("remote_id")
if not isinstance(response_id, str):
self._retry_or_dead_letter(event, int(row["attempts"]), "missing_response_id")
continue
try:
status, decision = self.responses.poll(response_id)
if status in {"queued", "in_progress"}:
attempts = int(row["attempts"])
if attempts + 1 > self.config.max_retries:
self._retry_or_dead_letter(
event, attempts, f"response_{status}_retry_limit"
)
continue
self.store.update_event_trigger(
event.event_id,
status="POLLING_RESPONSE",
channel="responses_api",
remote_id=response_id,
retry_after=self.config.poll_interval_seconds,
increment_attempt=True,
)
continue
if decision is None:
raise TriggerError("completed_without_decision")
self.submit_decision(decision.to_dict())
except OrchestratorError as exc:
self._retry_or_dead_letter(event, int(row["attempts"]), str(exc))
def dispatch_work_units(self) -> None:
rows = self.store.claim_outbox(
owner=self.worker_id,
limit=64,
lease_seconds=self.config.lease_seconds,
)
for row in rows:
try:
outbox_path, queue_path = self.dispatch.dispatch(row)
self.store.mark_outbox(row["work_unit_id"], status="DISPATCHED")
payload = {
"assigned_agent": row["assigned_agent"],
"event_id": row["event_id"],
"dispatch_outbox": str(outbox_path),
"command_queue": str(queue_path),
}
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=self._project_for_event(row["event_id"]),
kind="TASK_DISPATCHED",
work_unit_id=row["work_unit_id"],
payload=payload,
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=record["project_key"],
work_unit_id=row["work_unit_id"],
kind=record["kind"],
payload=record,
)
except Exception:
attempts = int(row["attempts"])
status = "DEAD_LETTER" if attempts >= self.config.max_retries else "RETRY"
self.store.mark_outbox(
row["work_unit_id"],
status=status,
retry_after=self._backoff(attempts),
increment_attempt=True,
)
LOG.exception("dispatch_failed work_unit=%s", row["work_unit_id"])
def synchronize_openclaw(self) -> None:
rows = self.store.claim_lifecycle(
owner=self.worker_id,
limit=64,
lease_seconds=self.config.lease_seconds,
)
for row in rows:
try:
payload = json.loads(row["payload_json"])
self.synchronizer.emit(payload)
self.store.mark_lifecycle(row["sync_id"], status="SENT")
except Exception:
attempts = int(row["attempts"])
status = "DEAD_LETTER" if attempts >= self.config.max_retries else "RETRY"
self.store.mark_lifecycle(
row["sync_id"],
status=status,
retry_after=self._backoff(attempts),
increment_attempt=True,
)
LOG.exception("openclaw_sync_failed sync_id=%s", row["sync_id"])
def enqueue_periodic_summaries(self) -> None:
for state in self.store.all_project_states():
project_key = state["project_key"]
if not self.store.summary_due(project_key, self.config.summary_interval_seconds):
continue
summary = self.store.project_summary(project_key)
if summary is None:
continue
digest = hashlib.sha256(
json.dumps(summary, separators=(",", ":"), sort_keys=True).encode()
).hexdigest()
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=project_key,
kind="PROJECT_SUMMARY",
work_unit_id=None,
payload=summary,
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=project_key,
work_unit_id=None,
kind=record["kind"],
payload=record,
)
self.store.mark_summary(project_key, digest)
def _enqueue_event_lifecycle(self, event: Event) -> None:
if event.event_type == "work_unit_started":
kind = "TASK_STARTED"
elif event.event_type in {"work_unit_completed", "work_unit_failed", "work_unit_blocked"}:
kind = "TASK_ENDED"
else:
kind = "EVENT_RECEIVED"
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=event.project_key,
kind=kind,
work_unit_id=event.work_unit_id,
payload=event.to_dict(),
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=event.project_key,
work_unit_id=event.work_unit_id,
kind=kind,
payload=record,
)
def _enqueue_decision_lifecycle(self, decision: Decision, version: int) -> None:
kind = "PROJECT_COMPLETED" if decision.verdict == "COMPLETE" else "DECISION_APPLIED"
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=decision.project_key,
kind=kind,
work_unit_id=None,
payload={"decision": decision.to_dict(), "state_version": version},
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=decision.project_key,
work_unit_id=None,
kind=kind,
payload=record,
)
summary = self.store.project_summary(decision.project_key)
if summary:
summary_record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=decision.project_key,
kind="PROJECT_SUMMARY",
work_unit_id=None,
payload=summary,
)
self.store.enqueue_lifecycle(
sync_id=summary_record["sync_id"],
project_key=decision.project_key,
work_unit_id=None,
kind=summary_record["kind"],
payload=summary_record,
)
def _project_for_event(self, event_id: str) -> str:
rows = self.store._conn().execute(
"SELECT project_key FROM events WHERE event_id=?",
(event_id,),
).fetchone()
if rows is None:
raise ConflictError("event_not_found")
return str(rows["project_key"])
def _retry_or_dead_letter(self, event: Event, attempts: int, reason: str) -> None:
next_attempt = attempts + 1
if next_attempt > self.config.max_retries:
self.store.update_event_trigger(
event.event_id,
status="DEAD_LETTER",
channel=None,
remote_id=None,
increment_attempt=True,
)
record = lifecycle_record(
contract_commit=self.config.contract_commit,
project_key=event.project_key,
kind="TASK_ENDED",
work_unit_id=event.work_unit_id,
payload={
"status": "FAIL",
"reason": reason,
"event_id": event.event_id,
"attempts": next_attempt,
},
)
self.store.enqueue_lifecycle(
sync_id=record["sync_id"],
project_key=event.project_key,
work_unit_id=event.work_unit_id,
kind=record["kind"],
payload=record,
)
return
self.store.update_event_trigger(
event.event_id,
status="TRIGGER_FAILED",
channel=None,
remote_id=None,
retry_after=self._backoff(attempts),
increment_attempt=True,
)
def _backoff(self, attempts: int) -> int:
return min(3600, self.config.retry_base_seconds * (2 ** min(attempts, 10)))
class Handler(BaseHTTPRequestHandler):
runtime: Runtime
def log_message(self, fmt: str, *args: object) -> None:
LOG.info("%s %s", self.client_address[0], fmt % args)
def _reply(self, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict[str, Any]:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as exc:
raise ValidationError("invalid_content_length") from exc
if length <= 0 or length > 1_048_576:
raise ValidationError("invalid_body_size")
try:
value = json.loads(self.rfile.read(length))
except json.JSONDecodeError as exc:
raise ValidationError("invalid_json") from exc
if not isinstance(value, dict):
raise ValidationError("json_root_not_object")
return value
def _authenticate(self, expected: str) -> None:
header = self.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise AuthenticationError("missing_bearer")
if not hmac.compare_digest(header[7:], expected):
raise AuthenticationError("invalid_bearer")
def do_GET(self) -> None:
path = urlparse(self.path).path.rstrip("/") or "/"
try:
if path == "/healthz":
self._reply(
200,
{
"service": "aios-orchestrator-gateway",
"status": "active",
"contract_commit": self.runtime.config.contract_commit,
"polling": True,
},
)
return
if path.startswith("/v1/orchestrator/state/"):
self._authenticate(self.runtime.config.inbound_token())
key = path.rsplit("/", 1)[-1]
state = self.runtime.store.project_summary(key)
self._reply(200 if state else 404, state or {"error": "project_not_found"})
return
self._reply(404, {"error": "not_found"})
except AuthenticationError as exc:
self._reply(401, {"error": str(exc)})
except OrchestratorError as exc:
self._reply(400, {"error": str(exc)})
def do_POST(self) -> None:
path = urlparse(self.path).path.rstrip("/")
try:
if path == "/v1/orchestrator/events":
self._authenticate(self.runtime.config.inbound_token())
created, event = self.runtime.submit_event(self._body())
self._reply(202 if created else 200, {"created": created, "event": event})
return
if path == "/v1/orchestrator/decisions":
self._authenticate(self.runtime.config.callback_token())
version = self.runtime.submit_decision(self._body())
self._reply(202, {"accepted": True, "state_version": version})
return
if path == "/v1/orchestrator/poll":
self._authenticate(self.runtime.config.inbound_token())
self.runtime.poll_once()
self._reply(200, {"polled": True})
return
self._reply(404, {"error": "not_found"})
except AuthenticationError as exc:
self._reply(401, {"error": str(exc)})
except ConflictError as exc:
self._reply(409, {"error": str(exc)})
except ValidationError as exc:
self._reply(422, {"error": str(exc)})
except OrchestratorError as exc:
self._reply(400, {"error": str(exc)})
except Exception:
LOG.exception("request_failed path=%s", path)
self._reply(500, {"error": "internal_error"})
def serve(config_path: Path) -> None:
config = Config.from_file(config_path)
runtime = Runtime(config)
Handler.runtime = runtime
server = ThreadingHTTPServer((config.host, config.port), Handler)
runtime.start()
LOG.info(
"gateway_started host=%s port=%s contract=%s polling=%ss",
config.host,
config.port,
config.contract_commit,
config.poll_interval_seconds,
)
try:
server.serve_forever(poll_interval=0.5)
finally:
server.server_close()
runtime.stop()
+826
View File
@@ -0,0 +1,826 @@
from __future__ import annotations
import json
from pathlib import Path
import sqlite3
import threading
import time
from typing import Any
from .errors import ConflictError
from .models import Decision, Event
from .securefs import ensure_secure_directory
class Store:
"""Durable state machine with idempotency, optimistic versioning, and leases."""
def __init__(self, path: Path):
self.path = Path(path)
ensure_secure_directory(self.path.parent)
self._local = threading.local()
self._initialize()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path, timeout=30, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=FULL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def _conn(self) -> sqlite3.Connection:
conn = getattr(self._local, "conn", None)
if conn is None:
conn = self._connect()
self._local.conn = conn
return conn
def _initialize(self) -> None:
conn = self._connect()
try:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS project_state (
project_key TEXT PRIMARY KEY,
contract_commit TEXT NOT NULL,
state_version INTEGER NOT NULL,
state_json TEXT NOT NULL,
hops INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS gate_hops (
project_key TEXT NOT NULL,
gate_id TEXT NOT NULL,
hops INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY(project_key, gate_id)
);
CREATE TABLE IF NOT EXISTS events (
event_id TEXT PRIMARY KEY,
project_key TEXT NOT NULL,
digest TEXT NOT NULL,
event_json TEXT NOT NULL,
status TEXT NOT NULL,
channel TEXT,
remote_id TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
lease_owner TEXT,
lease_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_poll
ON events(status, next_attempt_at, lease_until, created_at);
CREATE TABLE IF NOT EXISTS decisions (
event_id TEXT PRIMARY KEY REFERENCES events(event_id),
project_key TEXT NOT NULL,
decision_json TEXT NOT NULL,
state_version_after INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
work_unit_id TEXT PRIMARY KEY,
event_id TEXT NOT NULL REFERENCES events(event_id),
assigned_agent TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
lease_owner TEXT,
lease_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outbox_poll
ON outbox(status, next_attempt_at, lease_until, created_at);
CREATE TABLE IF NOT EXISTS lifecycle (
sync_id TEXT PRIMARY KEY,
project_key TEXT NOT NULL,
work_unit_id TEXT,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
lease_owner TEXT,
lease_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_lifecycle_poll
ON lifecycle(status, next_attempt_at, lease_until, created_at);
CREATE TABLE IF NOT EXISTS blocker_history (
project_key TEXT NOT NULL,
gate_id TEXT NOT NULL,
blocker TEXT NOT NULL,
repetitions INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(project_key, gate_id, blocker)
);
CREATE TABLE IF NOT EXISTS summaries (
project_key TEXT PRIMARY KEY,
last_emitted_at INTEGER NOT NULL DEFAULT 0,
last_digest TEXT
);
CREATE TABLE IF NOT EXISTS observed_refs (
refname TEXT PRIMARY KEY,
object_id TEXT NOT NULL,
observed_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
finally:
conn.close()
def register_event(self, event: Event) -> tuple[bool, dict[str, Any]]:
now = int(time.time())
conn = self._conn()
conn.execute("BEGIN IMMEDIATE")
try:
existing = conn.execute(
"SELECT digest, event_json FROM events WHERE event_id=?",
(event.event_id,),
).fetchone()
if existing:
if existing["digest"] != event.digest():
raise ConflictError("event_id_payload_conflict")
conn.execute("COMMIT")
return False, json.loads(existing["event_json"])
state = conn.execute(
"SELECT contract_commit, state_version FROM project_state WHERE project_key=?",
(event.project_key,),
).fetchone()
if state is None:
conn.execute(
"""
INSERT INTO project_state(
project_key, contract_commit, state_version, state_json,
hops, updated_at
) VALUES (?, ?, ?, '{}', 0, ?)
""",
(event.project_key, event.contract_commit, event.state_version, now),
)
else:
if state["contract_commit"] != event.contract_commit:
raise ConflictError("contract_version_mismatch")
if state["state_version"] != event.state_version:
raise ConflictError("stale_state_version")
conn.execute(
"""
INSERT INTO events(
event_id, project_key, digest, event_json, status,
next_attempt_at, created_at, updated_at
) VALUES (?, ?, ?, ?, 'RECEIVED', ?, ?, ?)
""",
(
event.event_id,
event.project_key,
event.digest(),
event.canonical_json(),
now,
now,
now,
),
)
conn.execute("COMMIT")
return True, event.to_dict()
except Exception:
conn.execute("ROLLBACK")
raise
def claim_events(
self,
*,
owner: str,
statuses: tuple[str, ...],
limit: int,
lease_seconds: int,
) -> list[dict[str, Any]]:
if not statuses:
return []
now = int(time.time())
placeholders = ",".join("?" for _ in statuses)
conn = self._conn()
conn.execute("BEGIN IMMEDIATE")
try:
rows = conn.execute(
f"""
SELECT event_id FROM events
WHERE status IN ({placeholders})
AND next_attempt_at <= ?
AND (lease_until IS NULL OR lease_until < ?)
ORDER BY created_at
LIMIT ?
""",
(*statuses, now, now, limit),
).fetchall()
ids = [row["event_id"] for row in rows]
for event_id in ids:
conn.execute(
"""
UPDATE events SET lease_owner=?, lease_until=?, updated_at=?
WHERE event_id=?
""",
(owner, now + lease_seconds, now, event_id),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
if not ids:
return []
placeholders = ",".join("?" for _ in ids)
return [
dict(row)
for row in conn.execute(
f"SELECT * FROM events WHERE event_id IN ({placeholders})",
ids,
).fetchall()
]
def update_event_trigger(
self,
event_id: str,
*,
status: str,
channel: str | None,
remote_id: str | None,
retry_after: int = 0,
increment_attempt: bool = False,
) -> None:
now = int(time.time())
self._conn().execute(
"""
UPDATE events
SET status=?, channel=?, remote_id=?,
attempts=attempts + ?,
next_attempt_at=?,
lease_owner=NULL, lease_until=NULL, updated_at=?
WHERE event_id=?
""",
(
status,
channel,
remote_id,
1 if increment_attempt else 0,
now + max(0, retry_after),
now,
event_id,
),
)
def claim_workspace_timeouts(
self,
*,
owner: str,
older_than: int,
limit: int,
lease_seconds: int,
) -> list[dict[str, Any]]:
return self._claim_event_subset(
owner=owner,
where_sql="status='AWAITING_DECISION' AND channel='workspace_agent' AND updated_at <= ?",
where_params=(older_than,),
limit=limit,
lease_seconds=lease_seconds,
)
def claim_response_polls(
self,
*,
owner: str,
limit: int,
lease_seconds: int,
) -> list[dict[str, Any]]:
now = int(time.time())
return self._claim_event_subset(
owner=owner,
where_sql="status='POLLING_RESPONSE' AND next_attempt_at <= ?",
where_params=(now,),
limit=limit,
lease_seconds=lease_seconds,
)
def _claim_event_subset(
self,
*,
owner: str,
where_sql: str,
where_params: tuple[Any, ...],
limit: int,
lease_seconds: int,
) -> list[dict[str, Any]]:
allowed = {
"status='AWAITING_DECISION' AND channel='workspace_agent' AND updated_at <= ?",
"status='POLLING_RESPONSE' AND next_attempt_at <= ?",
}
if where_sql not in allowed:
raise ValueError("invalid_event_claim_query")
now = int(time.time())
conn = self._conn()
conn.execute("BEGIN IMMEDIATE")
try:
rows = conn.execute(
f"""
SELECT event_id FROM events
WHERE {where_sql}
AND (lease_until IS NULL OR lease_until < ?)
ORDER BY updated_at
LIMIT ?
""",
(*where_params, now, limit),
).fetchall()
ids = [str(row["event_id"]) for row in rows]
for event_id in ids:
conn.execute(
"""
UPDATE events SET lease_owner=?, lease_until=?
WHERE event_id=?
""",
(owner, now + lease_seconds, event_id),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
if not ids:
return []
placeholders = ",".join("?" for _ in ids)
return [
dict(row)
for row in conn.execute(
f"SELECT * FROM events WHERE event_id IN ({placeholders})",
ids,
).fetchall()
]
def apply_decision(
self,
decision: Decision,
*,
max_hops_per_gate: int,
max_same_blocker_retries: int,
) -> int:
now = int(time.time())
conn = self._conn()
conn.execute("BEGIN IMMEDIATE")
try:
event = conn.execute(
"SELECT project_key FROM events WHERE event_id=?",
(decision.event_id,),
).fetchone()
if event is None:
raise ConflictError("event_not_found")
if event["project_key"] != decision.project_key:
raise ConflictError("event_project_mismatch")
existing = conn.execute(
"SELECT decision_json, state_version_after FROM decisions WHERE event_id=?",
(decision.event_id,),
).fetchone()
if existing:
if json.loads(existing["decision_json"]) != decision.to_dict():
raise ConflictError("decision_idempotency_conflict")
conn.execute("COMMIT")
return int(existing["state_version_after"])
state = conn.execute(
"SELECT * FROM project_state WHERE project_key=?",
(decision.project_key,),
).fetchone()
if state is None:
raise ConflictError("project_state_not_found")
if state["contract_commit"] != decision.contract_commit:
raise ConflictError("contract_version_mismatch")
if state["state_version"] != decision.expected_state_version:
raise ConflictError("stale_decision_state_version")
state_json = json.loads(state["state_json"])
gate = str(
decision.state_updates.get(
"current_gate", state_json.get("current_gate", "unknown")
)
)
gate_hops = conn.execute(
"SELECT hops FROM gate_hops WHERE project_key=? AND gate_id=?",
(decision.project_key, gate),
).fetchone()
if gate_hops is not None and int(gate_hops["hops"]) >= max_hops_per_gate:
raise ConflictError("max_hops_exceeded")
state_json.update(decision.state_updates)
next_version = int(state["state_version"]) + 1
if decision.stop_reason:
row = conn.execute(
"""
SELECT repetitions FROM blocker_history
WHERE project_key=? AND gate_id=? AND blocker=?
""",
(decision.project_key, gate, decision.stop_reason),
).fetchone()
repetitions = (int(row["repetitions"]) if row else 0) + 1
conn.execute(
"""
INSERT INTO blocker_history(
project_key, gate_id, blocker, repetitions, updated_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(project_key, gate_id, blocker)
DO UPDATE SET repetitions=excluded.repetitions,
updated_at=excluded.updated_at
""",
(decision.project_key, gate, decision.stop_reason, repetitions, now),
)
if repetitions > max_same_blocker_retries:
raise ConflictError("same_blocker_retry_limit_exceeded")
conn.execute(
"""
INSERT INTO gate_hops(project_key, gate_id, hops, updated_at)
VALUES (?, ?, 1, ?)
ON CONFLICT(project_key, gate_id)
DO UPDATE SET hops=gate_hops.hops + 1, updated_at=excluded.updated_at
""",
(decision.project_key, gate, now),
)
conn.execute(
"""
UPDATE project_state
SET state_version=?, state_json=?, hops=hops+1, updated_at=?
WHERE project_key=?
""",
(
next_version,
json.dumps(state_json, separators=(",", ":"), sort_keys=True),
now,
decision.project_key,
),
)
conn.execute(
"""
INSERT INTO decisions(
event_id, project_key, decision_json,
state_version_after, created_at
) VALUES (?, ?, ?, ?, ?)
""",
(
decision.event_id,
decision.project_key,
json.dumps(decision.to_dict(), separators=(",", ":"), sort_keys=True),
next_version,
now,
),
)
for unit in decision.next_work_units:
conn.execute(
"""
INSERT INTO outbox(
work_unit_id, event_id, assigned_agent, payload_json,
status, next_attempt_at, created_at, updated_at
) VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?)
""",
(
unit.id,
decision.event_id,
unit.primary_agent,
json.dumps(unit.to_dict(), separators=(",", ":"), sort_keys=True),
now,
now,
now,
),
)
conn.execute(
"""
UPDATE events SET status='DECIDED', lease_owner=NULL,
lease_until=NULL, updated_at=?
WHERE event_id=?
""",
(now, decision.event_id),
)
conn.execute("COMMIT")
return next_version
except Exception:
conn.execute("ROLLBACK")
raise
def claim_outbox(self, *, owner: str, limit: int, lease_seconds: int) -> list[dict[str, Any]]:
return self._claim_table(
table="outbox",
key="work_unit_id",
owner=owner,
statuses=("PENDING", "RETRY"),
limit=limit,
lease_seconds=lease_seconds,
)
def mark_outbox(
self,
work_unit_id: str,
*,
status: str,
retry_after: int = 0,
increment_attempt: bool = False,
) -> None:
self._mark_table(
table="outbox",
key="work_unit_id",
value=work_unit_id,
status=status,
retry_after=retry_after,
increment_attempt=increment_attempt,
)
def enqueue_lifecycle(
self,
*,
sync_id: str,
project_key: str,
work_unit_id: str | None,
kind: str,
payload: dict[str, Any],
) -> None:
now = int(time.time())
encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True)
self._conn().execute(
"""
INSERT OR IGNORE INTO lifecycle(
sync_id, project_key, work_unit_id, kind, payload_json,
status, next_attempt_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
""",
(sync_id, project_key, work_unit_id, kind, encoded, now, now, now),
)
def claim_lifecycle(self, *, owner: str, limit: int, lease_seconds: int) -> list[dict[str, Any]]:
return self._claim_table(
table="lifecycle",
key="sync_id",
owner=owner,
statuses=("PENDING", "RETRY"),
limit=limit,
lease_seconds=lease_seconds,
)
def mark_lifecycle(
self,
sync_id: str,
*,
status: str,
retry_after: int = 0,
increment_attempt: bool = False,
) -> None:
self._mark_table(
table="lifecycle",
key="sync_id",
value=sync_id,
status=status,
retry_after=retry_after,
increment_attempt=increment_attempt,
)
def _claim_table(
self,
*,
table: str,
key: str,
owner: str,
statuses: tuple[str, ...],
limit: int,
lease_seconds: int,
) -> list[dict[str, Any]]:
if table not in {"outbox", "lifecycle"} or key not in {"work_unit_id", "sync_id"}:
raise ValueError("invalid_claim_table")
now = int(time.time())
placeholders = ",".join("?" for _ in statuses)
conn = self._conn()
conn.execute("BEGIN IMMEDIATE")
try:
rows = conn.execute(
f"""
SELECT {key} FROM {table}
WHERE status IN ({placeholders})
AND next_attempt_at <= ?
AND (lease_until IS NULL OR lease_until < ?)
ORDER BY created_at
LIMIT ?
""",
(*statuses, now, now, limit),
).fetchall()
values = [row[key] for row in rows]
for item in values:
conn.execute(
f"""
UPDATE {table} SET lease_owner=?, lease_until=?, updated_at=?
WHERE {key}=?
""",
(owner, now + lease_seconds, now, item),
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
if not values:
return []
placeholders = ",".join("?" for _ in values)
return [
dict(row)
for row in conn.execute(
f"SELECT * FROM {table} WHERE {key} IN ({placeholders})",
values,
).fetchall()
]
def _mark_table(
self,
*,
table: str,
key: str,
value: str,
status: str,
retry_after: int,
increment_attempt: bool,
) -> None:
if table not in {"outbox", "lifecycle"} or key not in {"work_unit_id", "sync_id"}:
raise ValueError("invalid_mark_table")
now = int(time.time())
self._conn().execute(
f"""
UPDATE {table}
SET status=?, attempts=attempts + ?, next_attempt_at=?,
lease_owner=NULL, lease_until=NULL, updated_at=?
WHERE {key}=?
""",
(
status,
1 if increment_attempt else 0,
now + max(0, retry_after),
now,
value,
),
)
def project_state(self, project_key: str) -> dict[str, Any] | None:
row = self._conn().execute(
"SELECT * FROM project_state WHERE project_key=?",
(project_key,),
).fetchone()
if row is None:
return None
result = dict(row)
result["state"] = json.loads(result.pop("state_json"))
return result
def all_project_states(self) -> list[dict[str, Any]]:
result = []
for row in self._conn().execute("SELECT * FROM project_state ORDER BY project_key").fetchall():
item = dict(row)
item["state"] = json.loads(item.pop("state_json"))
result.append(item)
return result
def summary_due(self, project_key: str, interval: int) -> bool:
now = int(time.time())
row = self._conn().execute(
"SELECT last_emitted_at FROM summaries WHERE project_key=?",
(project_key,),
).fetchone()
return row is None or now - int(row["last_emitted_at"]) >= interval
def mark_summary(self, project_key: str, digest: str) -> None:
now = int(time.time())
self._conn().execute(
"""
INSERT INTO summaries(project_key, last_emitted_at, last_digest)
VALUES (?, ?, ?)
ON CONFLICT(project_key)
DO UPDATE SET last_emitted_at=excluded.last_emitted_at,
last_digest=excluded.last_digest
""",
(project_key, now, digest),
)
def project_summary(self, project_key: str) -> dict[str, Any] | None:
state = self.project_state(project_key)
if state is None:
return None
conn = self._conn()
event_counts = {
row["status"]: int(row["count"])
for row in conn.execute(
"""
SELECT status, COUNT(*) AS count FROM events
WHERE project_key=? GROUP BY status
""",
(project_key,),
).fetchall()
}
outbox_counts = {
row["status"]: int(row["count"])
for row in conn.execute(
"""
SELECT o.status, COUNT(*) AS count
FROM outbox o JOIN events e ON e.event_id=o.event_id
WHERE e.project_key=? GROUP BY o.status
""",
(project_key,),
).fetchall()
}
return {
"project_key": project_key,
"contract_commit": state["contract_commit"],
"state_version": state["state_version"],
"state": state["state"],
"event_counts": event_counts,
"work_unit_counts": outbox_counts,
"updated_at": state["updated_at"],
}
def metadata_get(self, key: str) -> str | None:
row = self._conn().execute(
"SELECT value FROM metadata WHERE key=?",
(key,),
).fetchone()
return None if row is None else str(row["value"])
def metadata_set(self, key: str, value: str) -> None:
self._conn().execute(
"""
INSERT INTO metadata(key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value
""",
(key, value),
)
def observe_refs(self, refs: dict[str, str]) -> tuple[bool, list[tuple[str, str | None, str]]]:
"""Persist a Git snapshot and return changes after the initial baseline."""
now = int(time.time())
conn = self._conn()
conn.execute("BEGIN IMMEDIATE")
try:
initialized = conn.execute(
"SELECT value FROM metadata WHERE key='git_poll_initialized'"
).fetchone()
previous = {
row["refname"]: row["object_id"]
for row in conn.execute("SELECT refname, object_id FROM observed_refs").fetchall()
}
changes: list[tuple[str, str | None, str]] = []
if initialized is not None:
for refname, oid in refs.items():
old = previous.get(refname)
if old != oid:
changes.append((refname, old, oid))
for refname, old in previous.items():
if refname not in refs:
changes.append((refname, old, "0" * 40))
conn.execute("DELETE FROM observed_refs")
for refname, oid in refs.items():
conn.execute(
"""
INSERT INTO observed_refs(refname, object_id, observed_at)
VALUES (?, ?, ?)
""",
(refname, oid, now),
)
conn.execute(
"""
INSERT INTO metadata(key, value)
VALUES ('git_poll_initialized', '1')
ON CONFLICT(key) DO UPDATE SET value='1'
"""
)
conn.execute("COMMIT")
return initialized is not None, changes
except Exception:
conn.execute("ROLLBACK")
raise
def close(self) -> None:
conn = getattr(self._local, "conn", None)
if conn is not None:
conn.close()
self._local.conn = None
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import hashlib
import json
import os
import stat
from pathlib import Path
import urllib.error
import urllib.request
from typing import Any
from .errors import DispatchError
from .securefs import atomic_write_bytes_shared, ensure_shared_directory, read_secure_bytes
class OpenClawSynchronizer:
"""Synchronize task starts, ends, dispatches, and project summaries.
A durable file is always emitted. An optional HTTP endpoint is best-effort;
failure leaves the lifecycle record pending for retry.
"""
def __init__(
self,
queue_dir: Path,
*,
endpoint: str | None = None,
bearer_token: str | None = None,
timeout_seconds: int = 10,
):
self.queue_dir = Path(queue_dir)
self.endpoint = endpoint
self.bearer_token = bearer_token
self.timeout_seconds = timeout_seconds
ensure_shared_directory(self.queue_dir)
def emit(self, record: dict[str, Any]) -> Path:
sync_id = record.get("sync_id")
if not isinstance(sync_id, str) or not sync_id:
raise DispatchError("invalid_sync_id")
payload = (json.dumps(record, indent=2, sort_keys=True) + "\n").encode()
path = self.queue_dir / "pending" / f"{sync_id}.json"
ensure_shared_directory(path.parent)
try:
st = os.lstat(path)
except FileNotFoundError:
atomic_write_bytes_shared(path, payload, mode=0o640)
else:
if not stat.S_ISREG(st.st_mode):
raise DispatchError("sync_target_not_regular")
if read_secure_bytes(path, expected_uid=None, max_bytes=1_048_576) != payload:
raise DispatchError("sync_idempotency_conflict")
if self.endpoint:
headers = {
"Content-Type": "application/json",
"Idempotency-Key": sync_id,
"User-Agent": "aios-orchestrator-gateway/0.2",
}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
request = urllib.request.Request(
self.endpoint,
data=payload,
method="POST",
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
response.read(65_536)
if response.status not in {200, 201, 202, 204}:
raise DispatchError(f"openclaw_sync_http_{response.status}")
except urllib.error.HTTPError as exc:
raise DispatchError(f"openclaw_sync_http_{exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise DispatchError("openclaw_sync_failed") from exc
return path
def lifecycle_record(
*,
contract_commit: str,
project_key: str,
kind: str,
work_unit_id: str | None,
payload: dict[str, Any],
) -> dict[str, Any]:
canonical = json.dumps(
{
"contract_commit": contract_commit,
"project_key": project_key,
"kind": kind,
"work_unit_id": work_unit_id,
"payload": payload,
},
separators=(",", ":"),
sort_keys=True,
)
sync_id = hashlib.sha256(canonical.encode()).hexdigest()
return {
"schema_version": 1,
"sync_id": sync_id,
"contract_commit": contract_commit,
"project_key": project_key,
"kind": kind,
"work_unit_id": work_unit_id,
"payload": payload,
}
@@ -0,0 +1,37 @@
[Unit]
Description=AIOS Orchestrator Gateway reliability poll
After=aios-orchestrator-gateway.service
ConditionPathExists=/etc/aios-orchestrator-gateway/config.json
[Service]
Type=oneshot
User=aios-orchestrator
Group=aios-orchestrator
SupplementaryGroups=gitops
WorkingDirectory=/opt/aios-orchestrator-gateway/current
ExecStart=/usr/bin/python3 -m services.aios_orchestrator_gateway --config /etc/aios-orchestrator-gateway/config.json --poll-once
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
RestrictNamespaces=true
MemoryDenyWriteExecute=true
RestrictSUIDSGID=true
LockPersonality=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
StateDirectory=aios-orchestrator-gateway
ReadOnlyPaths=/etc/aios-orchestrator-gateway /opt/aios-orchestrator-gateway
ReadWritePaths=/var/lib/aios-orchestrator-gateway
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
@@ -0,0 +1,12 @@
[Unit]
Description=Periodically poll AIOS orchestration inboxes and model responses
[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
RandomizedDelaySec=10s
Persistent=true
Unit=aios-orchestrator-gateway-poll.service
[Install]
WantedBy=timers.target
+52
View File
@@ -0,0 +1,52 @@
[Unit]
Description=AIOS Orchestrator Gateway
After=network-online.target
Wants=network-online.target
ConditionPathExists=/etc/aios-orchestrator-gateway/config.json
[Service]
Type=simple
User=aios-orchestrator
Group=aios-orchestrator
SupplementaryGroups=gitops
WorkingDirectory=/opt/aios-orchestrator-gateway/current
ExecStart=/usr/bin/python3 -m services.aios_orchestrator_gateway --config /etc/aios-orchestrator-gateway/config.json
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=30s
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
RestrictNamespaces=true
MemoryDenyWriteExecute=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
StateDirectory=aios-orchestrator-gateway
RuntimeDirectory=aios-orchestrator-gateway
ReadOnlyPaths=/etc/aios-orchestrator-gateway /opt/aios-orchestrator-gateway
ReadWritePaths=/var/lib/aios-orchestrator-gateway
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,2 @@
g aios-orchestrator -
u aios-orchestrator - "AIOS Orchestrator Gateway" /var/lib/aios-orchestrator-gateway /usr/sbin/nologin
@@ -0,0 +1,19 @@
d /etc/aios-orchestrator-gateway 0700 root root -
d /var/lib/aios-orchestrator-gateway 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox/events 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox/events/pending 2770 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/inbox/events/processed 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox/events/rejected 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox/decisions 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox/decisions/pending 2770 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/inbox/decisions/processed 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/inbox/decisions/rejected 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/outbox 0700 aios-orchestrator aios-orchestrator -
d /var/lib/aios-orchestrator-gateway/openclaw-sync 2750 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/openclaw-sync/pending 2750 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/commands 2750 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/commands/openclaw 2750 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/commands/openclaw/pending 2750 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/commands/codex 2750 aios-orchestrator gitops -
d /var/lib/aios-orchestrator-gateway/commands/codex/pending 2750 aios-orchestrator gitops -
+774
View File
@@ -0,0 +1,774 @@
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
import tempfile
import time
import unittest
import urllib.error
from unittest import mock
from services.aios_orchestrator_gateway.clients import ResponsesClient, WorkspaceAgentClient
from services.aios_orchestrator_gateway.config import Config
from services.aios_orchestrator_gateway.dispatch import DispatchSink
from services.aios_orchestrator_gateway.errors import ConflictError, DispatchError, ValidationError
from services.aios_orchestrator_gateway.models import Decision, Event
from services.aios_orchestrator_gateway.service import Runtime
from services.aios_orchestrator_gateway.store import Store
from services.aios_orchestrator_gateway.sync import OpenClawSynchronizer, lifecycle_record
from scripts import report_orchestrator_event
CONTRACT = "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc"
BASE = "8d9c6da90fc8caa69b691d243c6fefb40b6b0575"
def event_payload(**overrides):
value = {
"schema_version": 1,
"project_key": "v2r1-personal-weixin",
"contract_commit": CONTRACT,
"event_id": "evt-001",
"source_agent": "openclaw",
"event_type": "work_unit_completed",
"work_unit_id": "P1-REACK",
"status": "PASS",
"state_version": 0,
"implementation_commit": None,
"result_commit": None,
"blocker": None,
"evidence_refs": [],
"payload": {},
}
value.update(overrides)
return value
def decision_payload(**overrides):
value = {
"schema_version": 1,
"project_key": "v2r1-personal-weixin",
"contract_commit": CONTRACT,
"event_id": "evt-001",
"expected_state_version": 0,
"verdict": "DISPATCH",
"state_updates": [
{"key": "P1_REPAIR3_CODE", "value": "PASS_CANDIDATE_PREPARED"},
{"key": "current_gate", "value": "R0_RUNNER_LIVE_RECOVERY"},
],
"next_work_units": [
{
"id": "R0-DISCOVER",
"track_id": "R_RUNNER",
"gate_id": "R0_RUNNER_LIVE_RECOVERY",
"primary_agent": "openclaw",
"fallback_agent": "codex",
"clean_base_commit": BASE,
"allowed_paths": [],
"required_checks": ["secret_provider_metadata"],
"forbidden_operations": ["force_push"],
"instruction": "Discover provider metadata without reading secrets.",
}
],
"requires_human_input": False,
"stop_reason": None,
}
value.update(overrides)
return value
class ModelTests(unittest.TestCase):
def test_event_accepts_valid_payload(self):
event = Event.from_dict(event_payload())
self.assertEqual(event.event_id, "evt-001")
self.assertEqual(len(event.digest()), 64)
def test_event_rejects_extra_fields(self):
with self.assertRaises(ValidationError):
Event.from_dict(event_payload(extra=True))
def test_event_rejects_invalid_contract_oid(self):
with self.assertRaises(ValidationError):
Event.from_dict(event_payload(contract_commit="abc"))
def test_decision_requires_work_unit_for_dispatch(self):
with self.assertRaises(ValidationError):
Decision.from_dict(decision_payload(next_work_units=[]))
def test_decision_rejects_same_primary_fallback(self):
value = decision_payload()
value["next_work_units"][0]["fallback_agent"] = "openclaw"
with self.assertRaises(ValidationError):
Decision.from_dict(value)
def test_non_dispatch_rejects_work_units(self):
with self.assertRaises(ValidationError):
Decision.from_dict(decision_payload(verdict="BLOCKED"))
class ClientTests(unittest.TestCase):
def test_workspace_trigger_accepts_202_without_run_id(self):
event = Event.from_dict(event_payload())
response = mock.MagicMock()
response.status = 202
response.read.return_value = b""
response.__enter__.return_value = response
response.__exit__.return_value = False
with mock.patch(
"services.aios_orchestrator_gateway.clients.urllib.request.urlopen",
return_value=response,
) as call:
WorkspaceAgentClient("agtch_test", "token").trigger(event)
request = call.call_args.args[0]
self.assertEqual(request.headers["Idempotency-key"], "evt-001")
def test_responses_background_start_and_poll(self):
event = Event.from_dict(event_payload())
decision = decision_payload()
client = ResponsesClient("key", "gpt-test")
with mock.patch.object(
client,
"_request",
side_effect=[
{"id": "resp_123", "status": "queued"},
{
"id": "resp_123",
"status": "completed",
"output": [
{
"content": [
{
"type": "output_text",
"text": json.dumps(decision),
}
]
}
],
},
],
):
response_id = client.start(event)
status, parsed = client.poll(response_id)
self.assertEqual(response_id, "resp_123")
self.assertEqual(status, "completed")
self.assertEqual(parsed.event_id, "evt-001")
def test_responses_poll_keeps_queued_state(self):
client = ResponsesClient("key", "gpt-test")
with mock.patch.object(
client,
"_request",
return_value={"id": "resp_123", "status": "in_progress"},
):
status, parsed = client.poll("resp_123")
self.assertEqual(status, "in_progress")
self.assertIsNone(parsed)
def test_responses_poll_queued_in_progress_completed_sequence(self):
client = ResponsesClient("key", "gpt-test")
completed = {
"status": "completed",
"output": [{"content": [{"type": "output_text", "text": json.dumps(decision_payload())}]}],
}
with mock.patch.object(
client,
"_request",
side_effect=[{"status": "queued"}, {"status": "in_progress"}, completed],
):
self.assertEqual(client.poll("resp_123"), ("queued", None))
self.assertEqual(client.poll("resp_123"), ("in_progress", None))
status, decision = client.poll("resp_123")
self.assertEqual(status, "completed")
self.assertEqual(decision.event_id, "evt-001")
def test_responses_invalid_completed_decision_fails_closed(self):
client = ResponsesClient("key", "gpt-test")
payload = {"status": "completed", "output": [{"content": [{"type": "output_text", "text": "{}"}]}]}
with mock.patch.object(client, "_request", return_value=payload):
with self.assertRaises(ValidationError):
client.poll("resp_123")
class StoreTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.store = Store(Path(self.temp.name) / "state.sqlite3")
def tearDown(self):
self.store.close()
self.temp.cleanup()
def test_event_idempotency(self):
event = Event.from_dict(event_payload())
created, _ = self.store.register_event(event)
duplicate, _ = self.store.register_event(event)
self.assertTrue(created)
self.assertFalse(duplicate)
def test_event_payload_conflict_rejected(self):
self.store.register_event(Event.from_dict(event_payload()))
with self.assertRaises(ConflictError):
self.store.register_event(Event.from_dict(event_payload(status="FAIL")))
def test_stale_state_version_rejected(self):
self.store.register_event(Event.from_dict(event_payload()))
with self.assertRaises(ConflictError):
self.store.register_event(
Event.from_dict(event_payload(event_id="evt-002", state_version=1))
)
def test_lease_claim_prevents_double_claim(self):
self.store.register_event(Event.from_dict(event_payload()))
first = self.store.claim_events(
owner="one", statuses=("RECEIVED",), limit=5, lease_seconds=60
)
second_store = Store(Path(self.temp.name) / "state.sqlite3")
try:
second = second_store.claim_events(
owner="two", statuses=("RECEIVED",), limit=5, lease_seconds=60
)
finally:
second_store.close()
self.assertEqual(len(first), 1)
self.assertEqual(second, [])
def test_workspace_timeout_claim_prevents_double_worker(self):
self.store.register_event(Event.from_dict(event_payload()))
self.store.update_event_trigger("evt-001", status="AWAITING_DECISION", channel="workspace_agent", remote_id=None)
self.store._conn().execute("UPDATE events SET updated_at=0 WHERE event_id='evt-001'")
first = self.store.claim_workspace_timeouts(owner="one", older_than=1, limit=5, lease_seconds=60)
second = self.store.claim_workspace_timeouts(owner="two", older_than=1, limit=5, lease_seconds=60)
self.assertEqual(len(first), 1)
self.assertEqual(second, [])
def test_response_poll_claim_prevents_double_worker(self):
self.store.register_event(Event.from_dict(event_payload()))
self.store.update_event_trigger("evt-001", status="POLLING_RESPONSE", channel="responses_api", remote_id="resp_123")
first = self.store.claim_response_polls(owner="one", limit=5, lease_seconds=60)
second = self.store.claim_response_polls(owner="two", limit=5, lease_seconds=60)
self.assertEqual(len(first), 1)
self.assertEqual(second, [])
def test_max_hops_limit(self):
self.store.register_event(Event.from_dict(event_payload()))
self.store._conn().execute(
"""
INSERT INTO gate_hops(project_key, gate_id, hops, updated_at)
VALUES ('v2r1-personal-weixin', 'R0_RUNNER_LIVE_RECOVERY', 1, 0)
"""
)
with self.assertRaisesRegex(ConflictError, "max_hops_exceeded"):
self.store.apply_decision(
Decision.from_dict(decision_payload()),
max_hops_per_gate=1,
max_same_blocker_retries=3,
)
def test_hop_limit_is_scoped_per_gate(self):
self.store.register_event(Event.from_dict(event_payload()))
first = Decision.from_dict(decision_payload())
self.assertEqual(
self.store.apply_decision(
first, max_hops_per_gate=1, max_same_blocker_retries=3
),
1,
)
self.store.register_event(
Event.from_dict(event_payload(event_id="evt-002", state_version=1))
)
second_value = decision_payload(
event_id="evt-002",
expected_state_version=1,
state_updates=[{"key": "current_gate", "value": "P2_REAL_B3_EVIDENCE"}],
)
second_value["next_work_units"][0]["id"] = "P2-REVIEW"
second_value["next_work_units"][0]["gate_id"] = "P2_REAL_B3_EVIDENCE"
self.assertEqual(
self.store.apply_decision(
Decision.from_dict(second_value),
max_hops_per_gate=1,
max_same_blocker_retries=3,
),
2,
)
self.store.register_event(
Event.from_dict(event_payload(event_id="evt-003", state_version=2))
)
third_value = decision_payload(
event_id="evt-003",
expected_state_version=2,
state_updates=[{"key": "current_gate", "value": "P2_REAL_B3_EVIDENCE"}],
)
third_value["next_work_units"][0]["id"] = "P2-REVIEW-RETRY"
third_value["next_work_units"][0]["gate_id"] = "P2_REAL_B3_EVIDENCE"
with self.assertRaisesRegex(ConflictError, "max_hops_exceeded"):
self.store.apply_decision(
Decision.from_dict(third_value),
max_hops_per_gate=1,
max_same_blocker_retries=3,
)
def test_decision_advances_state_and_creates_outbox(self):
self.store.register_event(Event.from_dict(event_payload()))
version = self.store.apply_decision(
Decision.from_dict(decision_payload()),
max_hops_per_gate=20,
max_same_blocker_retries=3,
)
self.assertEqual(version, 1)
state = self.store.project_state("v2r1-personal-weixin")
self.assertEqual(state["state_version"], 1)
rows = self.store.claim_outbox(owner="x", limit=10, lease_seconds=60)
self.assertEqual(len(rows), 1)
def test_decision_idempotency(self):
self.store.register_event(Event.from_dict(event_payload()))
decision = Decision.from_dict(decision_payload())
first = self.store.apply_decision(
decision, max_hops_per_gate=20, max_same_blocker_retries=3
)
second = self.store.apply_decision(
decision, max_hops_per_gate=20, max_same_blocker_retries=3
)
self.assertEqual(first, second)
def test_same_blocker_limit(self):
self.store.register_event(Event.from_dict(event_payload()))
decision = Decision.from_dict(
decision_payload(
verdict="BLOCKED",
next_work_units=[],
stop_reason="SAME_BLOCKER",
)
)
self.store.apply_decision(
decision, max_hops_per_gate=20, max_same_blocker_retries=1
)
# New event at next state with the same blocker exceeds the configured limit.
self.store.register_event(
Event.from_dict(event_payload(event_id="evt-002", state_version=1))
)
decision2 = Decision.from_dict(
decision_payload(
event_id="evt-002",
expected_state_version=1,
verdict="BLOCKED",
next_work_units=[],
stop_reason="SAME_BLOCKER",
)
)
with self.assertRaises(ConflictError):
self.store.apply_decision(
decision2, max_hops_per_gate=20, max_same_blocker_retries=1
)
def test_project_summary_contains_counts(self):
self.store.register_event(Event.from_dict(event_payload()))
summary = self.store.project_summary("v2r1-personal-weixin")
self.assertEqual(summary["event_counts"]["RECEIVED"], 1)
class DispatchTests(unittest.TestCase):
def test_dispatch_is_idempotent(self):
with tempfile.TemporaryDirectory() as temp:
outbox = Path(temp) / "outbox"
queue = Path(temp) / "queue"
outbox.mkdir(mode=0o700)
queue.mkdir(mode=0o2770)
queue.chmod(0o2770)
sink = DispatchSink(outbox, queue, CONTRACT)
record = {
"work_unit_id": "R0-DISCOVER",
"event_id": "evt-001",
"assigned_agent": "openclaw",
"payload_json": json.dumps(decision_payload()["next_work_units"][0]),
}
first = sink.dispatch(record)
second = sink.dispatch(record)
self.assertEqual(first, second)
self.assertTrue(first[0].exists())
self.assertTrue(first[1].exists())
self.assertEqual(stat.S_IMODE(first[1].stat().st_mode), 0o640)
def test_dispatch_rejects_path_escape(self):
with tempfile.TemporaryDirectory() as temp:
outbox = Path(temp) / "outbox"
queue = Path(temp) / "queue"
outbox.mkdir(mode=0o700)
queue.mkdir(mode=0o2770)
queue.chmod(0o2770)
sink = DispatchSink(outbox, queue, CONTRACT)
with self.assertRaises(DispatchError):
sink.dispatch(
{
"work_unit_id": "../escape",
"event_id": "evt-001",
"assigned_agent": "openclaw",
"payload_json": "{}",
}
)
def test_dispatch_rejects_existing_symlink_target(self):
with tempfile.TemporaryDirectory() as temp:
outbox = Path(temp) / "outbox"
queue = Path(temp) / "queue"
outbox.mkdir(mode=0o700)
queue.mkdir(mode=0o2770)
queue.chmod(0o2770)
sink = DispatchSink(outbox, queue, CONTRACT)
target = Path(temp) / "target"
target.write_text("x")
pending = outbox / "dispatched"
pending.mkdir(mode=0o700)
(pending / "R0-DISCOVER.json").symlink_to(target)
with self.assertRaises(DispatchError):
sink.dispatch(
{
"work_unit_id": "R0-DISCOVER",
"event_id": "evt-001",
"assigned_agent": "openclaw",
"payload_json": json.dumps(decision_payload()["next_work_units"][0]),
}
)
class SyncTests(unittest.TestCase):
def test_lifecycle_record_is_stable(self):
one = lifecycle_record(
contract_commit=CONTRACT,
project_key="v2r1",
kind="TASK_STARTED",
work_unit_id="R0",
payload={"status": "RUNNING"},
)
two = lifecycle_record(
contract_commit=CONTRACT,
project_key="v2r1",
kind="TASK_STARTED",
work_unit_id="R0",
payload={"status": "RUNNING"},
)
self.assertEqual(one["sync_id"], two["sync_id"])
def test_file_sync_is_idempotent(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
root.chmod(0o2750)
sync = OpenClawSynchronizer(root)
record = lifecycle_record(
contract_commit=CONTRACT,
project_key="v2r1",
kind="PROJECT_SUMMARY",
work_unit_id=None,
payload={"done": 1},
)
first = sync.emit(record)
second = sync.emit(record)
self.assertEqual(first, second)
def test_http_failure_keeps_file_for_retry(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
root.chmod(0o2750)
sync = OpenClawSynchronizer(root, endpoint="https://openclaw.invalid/events")
record = lifecycle_record(
contract_commit=CONTRACT,
project_key="v2r1",
kind="PROJECT_COMPLETED",
work_unit_id=None,
payload={"done": 1},
)
with mock.patch(
"services.aios_orchestrator_gateway.sync.urllib.request.urlopen",
side_effect=OSError("offline"),
):
with self.assertRaises(DispatchError):
sync.emit(record)
pending = root / "pending" / f"{record['sync_id']}.json"
self.assertTrue(pending.exists())
response = mock.MagicMock(status=202)
response.__enter__.return_value = response
response.__exit__.return_value = False
with mock.patch(
"services.aios_orchestrator_gateway.sync.urllib.request.urlopen",
return_value=response,
):
self.assertEqual(sync.emit(record), pending)
class ReporterTests(unittest.TestCase):
def test_http_failure_falls_back_to_file_inbox(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
inbox = root / "inbox"
inbox.mkdir(mode=0o2770)
inbox.chmod(0o2770)
token = root / "token"
token.write_text("not-a-real-secret", encoding="utf-8")
token.chmod(0o600)
argv = [
"report_orchestrator_event.py",
"--project-key", "v2r1-personal-weixin",
"--contract-commit", CONTRACT,
"--event-id", "evt-http-fallback",
"--source-agent", "codex",
"--event-type", "work_unit_completed",
"--work-unit-id", "P1-REACK",
"--status", "PASS",
"--state-version", "0",
"--endpoint", "https://gateway.invalid",
"--token-file", str(token),
"--inbox-dir", str(inbox),
]
with mock.patch("sys.argv", argv), mock.patch(
"scripts.report_orchestrator_event.urllib.request.urlopen",
side_effect=urllib.error.URLError("offline"),
):
self.assertEqual(report_orchestrator_event.main(), 0)
pending = inbox / "pending/evt-http-fallback.json"
self.assertTrue(pending.exists())
self.assertEqual(stat.S_IMODE(pending.stat().st_mode), 0o640)
def test_payload_symlink_is_rejected(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
target = root / "payload.json"
target.write_text("{}", encoding="utf-8")
target.chmod(0o600)
link = root / "payload-link.json"
link.symlink_to(target)
argv = [
"report_orchestrator_event.py",
"--project-key", "v2r1-personal-weixin",
"--contract-commit", CONTRACT,
"--event-id", "evt-symlink",
"--source-agent", "codex",
"--event-type", "work_unit_completed",
"--work-unit-id", "P1-REACK",
"--status", "PASS",
"--state-version", "0",
"--payload-file", str(link),
"--inbox-dir", str(root / "inbox"),
]
with mock.patch("sys.argv", argv), self.assertRaisesRegex(
Exception, "symlink_rejected"
):
report_orchestrator_event.main()
class RuntimePollingTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
for name in ("events", "decisions", "outbox", "etc"):
(self.root / name).mkdir(mode=0o700)
for name in ("sync", "commands"):
(self.root / name).mkdir(mode=0o2750)
(self.root / name).chmod(0o2750)
for name, value in (
("inbound.token", "inbound"),
("callback.token", "callback"),
("workspace.token", "workspace"),
):
path = self.root / "etc" / name
path.write_text(value)
path.chmod(0o600)
config = {
"host": "127.0.0.1",
"port": 18920,
"project_key": "v2r1-personal-weixin-production",
"database_path": str(self.root / "state.sqlite3"),
"event_inbox_dir": str(self.root / "events"),
"decision_inbox_dir": str(self.root / "decisions"),
"dispatch_outbox_dir": str(self.root / "outbox"),
"openclaw_sync_dir": str(self.root / "sync"),
"command_queue_dir": str(self.root / "commands"),
"inbound_token_file": str(self.root / "etc/inbound.token"),
"callback_token_file": str(self.root / "etc/callback.token"),
"contract_commit": CONTRACT,
"workspace_agent": {
"trigger_id": "agtch_test",
"access_token_file": str(self.root / "etc/workspace.token"),
},
"responses_fallback": {
"api_key_file": None,
"model": None,
},
"polling": {
"interval_seconds": 1,
"summary_interval_seconds": 10,
},
"loop_controls": {
"callback_timeout_seconds": 30,
"lease_seconds": 10,
"retry_base_seconds": 1,
"max_retries": 2,
"max_same_blocker_retries": 2,
"max_hops_per_gate": 20,
},
"openclaw_sync": {"endpoint": None, "token_file": None},
"git_poll": {
"enabled": False,
"bare_repo": None,
"ref_prefixes": [],
"interval_seconds": 30,
},
}
config_path = self.root / "etc/config.json"
config_path.write_text(json.dumps(config))
config_path.chmod(0o600)
os.environ["AIOS_ORCHESTRATOR_CONFIG_OWNER_UID"] = str(os.getuid())
os.environ["AIOS_ORCHESTRATOR_SECRET_OWNER_UID"] = str(os.getuid())
self.runtime = Runtime(Config.from_file(config_path))
def tearDown(self):
self.runtime.store.close()
self.temp.cleanup()
os.environ.pop("AIOS_ORCHESTRATOR_CONFIG_OWNER_UID", None)
os.environ.pop("AIOS_ORCHESTRATOR_SECRET_OWNER_UID", None)
def test_file_event_polling_and_workspace_trigger(self):
pending = self.root / "events/pending"
pending.mkdir(mode=0o2770)
pending.chmod(0o2770)
(pending / "evt-001.json").write_text(json.dumps(event_payload()))
(pending / "evt-001.json").chmod(0o640)
with mock.patch.object(self.runtime.workspace, "trigger") as trigger:
self.runtime.poll_once()
trigger.assert_called_once()
processed = self.root / "events/processed/evt-001.json"
self.assertTrue(processed.exists())
def test_workspace_timeout_uses_responses_fallback(self):
self.runtime.submit_event(event_payload())
self.runtime.store.update_event_trigger(
"evt-001", status="AWAITING_DECISION", channel="workspace_agent", remote_id=None
)
self.runtime.store._conn().execute("UPDATE events SET updated_at=0 WHERE event_id='evt-001'")
fallback = mock.Mock()
fallback.start.return_value = "resp_fallback"
self.runtime.responses = fallback
self.runtime.process_workspace_timeouts()
row = self.runtime.store._conn().execute(
"SELECT status, channel, remote_id FROM events WHERE event_id='evt-001'"
).fetchone()
self.assertEqual(tuple(row), ("POLLING_RESPONSE", "responses_api", "resp_fallback"))
fallback.start.assert_called_once()
def test_workspace_callback_timeout_reaches_dead_letter_without_fallback(self):
self.runtime.submit_event(event_payload())
self.runtime.store.update_event_trigger(
"evt-001", status="AWAITING_DECISION", channel="workspace_agent", remote_id=None
)
self.runtime.store._conn().execute(
"UPDATE events SET attempts=2, updated_at=0 WHERE event_id='evt-001'"
)
self.assertIsNone(self.runtime.responses)
self.runtime.process_workspace_timeouts()
row = self.runtime.store._conn().execute(
"SELECT status, attempts FROM events WHERE event_id='evt-001'"
).fetchone()
self.assertEqual(tuple(row), ("DEAD_LETTER", 3))
def test_response_poll_limit_dead_letters_queued_response(self):
self.runtime.submit_event(event_payload())
self.runtime.store.update_event_trigger(
"evt-001",
status="POLLING_RESPONSE",
channel="responses_api",
remote_id="resp-stuck",
)
self.runtime.store._conn().execute(
"UPDATE events SET attempts=2, next_attempt_at=0 WHERE event_id='evt-001'"
)
fallback = mock.Mock()
fallback.poll.return_value = ("queued", None)
self.runtime.responses = fallback
self.runtime.poll_background_responses()
row = self.runtime.store._conn().execute(
"SELECT status, attempts FROM events WHERE event_id='evt-001'"
).fetchone()
self.assertEqual(tuple(row), ("DEAD_LETTER", 3))
def test_callback_rejects_wrong_contract(self):
self.runtime.submit_event(event_payload())
with self.assertRaisesRegex(ConflictError, "contract_version_mismatch"):
self.runtime.submit_decision(
decision_payload(contract_commit="f" * 40)
)
def test_task_start_and_end_are_synced(self):
start = Event.from_dict(
event_payload(
event_id="evt-start",
event_type="work_unit_started",
status="RUNNING",
)
)
self.runtime.submit_event(start.to_dict())
self.runtime.synchronize_openclaw()
pending = list((self.root / "sync/pending").glob("*.json"))
self.assertTrue(pending)
kinds = {json.loads(p.read_text())["kind"] for p in pending}
self.assertIn("TASK_STARTED", kinds)
def test_decision_dispatch_and_summary_sync(self):
self.runtime.submit_event(event_payload())
self.runtime.submit_decision(decision_payload())
self.runtime.dispatch_work_units()
self.runtime.synchronize_openclaw()
self.assertTrue(list((self.root / "commands/openclaw/pending").glob("*.json")))
kinds = {
json.loads(p.read_text())["kind"]
for p in (self.root / "sync/pending").glob("*.json")
}
self.assertIn("TASK_DISPATCHED", kinds)
self.assertIn("PROJECT_SUMMARY", kinds)
def test_poll_once_ingests_decision_file(self):
self.runtime.submit_event(event_payload())
pending = self.root / "decisions/pending"
pending.mkdir(mode=0o2770)
pending.chmod(0o2770)
(pending / "decision.json").write_text(json.dumps(decision_payload()))
(pending / "decision.json").chmod(0o640)
self.runtime.poll_once()
self.assertTrue((self.root / "decisions/processed/decision.json").exists())
def test_git_ref_polling_baselines_then_emits_change(self):
object.__setattr__(self.runtime.config, "git_poll_enabled", True)
object.__setattr__(self.runtime.config, "git_poll_bare_repo", self.root / "repo.git")
object.__setattr__(self.runtime.config, "git_poll_ref_prefixes", ("refs/heads/main",))
oid1 = "1" * 40
oid2 = "2" * 40
first = mock.Mock(returncode=0, stdout=f"refs/heads/main {oid1}\n", stderr="")
second = mock.Mock(returncode=0, stdout=f"refs/heads/main {oid2}\n", stderr="")
with mock.patch(
"services.aios_orchestrator_gateway.service.subprocess.run",
side_effect=[first, second],
):
self.runtime._next_git_poll = 0
self.runtime.poll_git_refs()
self.runtime._next_git_poll = 0
self.runtime.poll_git_refs()
summary = self.runtime.store.project_summary(
"v2r1-personal-weixin-production"
)
self.assertIsNotNone(summary)
self.assertEqual(summary["event_counts"]["RECEIVED"], 1)
def test_retry_backoff_is_bounded(self):
self.assertEqual(self.runtime._backoff(0), 1)
self.assertLessEqual(self.runtime._backoff(100), 3600)
if __name__ == "__main__":
unittest.main()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0077
SOURCE_ROOT="${1:?source root required}"
ROOT="$(mktemp -d /tmp/aios-orchestrator-rollback-test.XXXXXX)"
trap 'rm -rf "$ROOT"' EXIT
INSTALL_ROOT="$ROOT/opt"
STATE_ROOT="$ROOT/state"
mkdir -p "$INSTALL_ROOT/releases" "$STATE_ROOT" "$ROOT/bin"
chmod 0700 "$STATE_ROOT"
OLD="$(printf 'a%.0s' {1..64})"
NEW="$(printf 'b%.0s' {1..64})"
mkdir "$INSTALL_ROOT/releases/$OLD" "$INSTALL_ROOT/releases/$NEW"
printf '%s\n' "$OLD" > "$INSTALL_ROOT/releases/$OLD/SOURCE_DIGEST"
printf '%s\n' "$NEW" > "$INSTALL_ROOT/releases/$NEW/SOURCE_DIGEST"
ln -s "$INSTALL_ROOT/releases/$NEW" "$INSTALL_ROOT/current"
python3 - "$STATE_ROOT/install-receipt.json" "$INSTALL_ROOT/releases/$NEW" "$INSTALL_ROOT/releases/$OLD" <<'PY'
import json, sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump({
"schema_version": 1,
"source_digest": "b" * 64,
"current_release": sys.argv[2],
"previous_release": sys.argv[3],
"last_rollback": None,
}, handle)
PY
chmod 0600 "$STATE_ROOT/install-receipt.json"
cat > "$ROOT/bin/systemctl" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >> "${SYSTEMCTL_LOG:?}"
exit 0
SH
chmod 0755 "$ROOT/bin/systemctl"
: > "$ROOT/systemctl.log"
SYSTEMCTL_LOG="$ROOT/systemctl.log" PATH="$ROOT/bin:/usr/bin:/bin" INSTALL_ROOT="$INSTALL_ROOT" STATE_ROOT="$STATE_ROOT" \
bash "$SOURCE_ROOT/scripts/rollback_aios_orchestrator_gateway.sh"
FIRST="$(readlink -f "$INSTALL_ROOT/current")"
SYSTEMCTL_LOG="$ROOT/systemctl.log" PATH="$ROOT/bin:/usr/bin:/bin" INSTALL_ROOT="$INSTALL_ROOT" STATE_ROOT="$STATE_ROOT" \
bash "$SOURCE_ROOT/scripts/rollback_aios_orchestrator_gateway.sh"
SECOND="$(readlink -f "$INSTALL_ROOT/current")"
[[ "$FIRST" == "$INSTALL_ROOT/releases/$OLD" ]]
[[ "$SECOND" == "$FIRST" ]]
grep -Fxq 'stop aios-orchestrator-gateway-poll.timer' "$ROOT/systemctl.log"
grep -Fxq 'stop aios-orchestrator-gateway-poll.service' "$ROOT/systemctl.log"
grep -Fxq 'start aios-orchestrator-gateway-poll.timer' "$ROOT/systemctl.log"
printf 'tampered\n' > "$INSTALL_ROOT/releases/$OLD/SOURCE_DIGEST"
if SYSTEMCTL_LOG="$ROOT/systemctl.log" PATH="$ROOT/bin:/usr/bin:/bin" \
INSTALL_ROOT="$INSTALL_ROOT" STATE_ROOT="$STATE_ROOT" \
bash "$SOURCE_ROOT/scripts/rollback_aios_orchestrator_gateway.sh" >/dev/null 2>&1; then
echo 'ROLLBACK_DIGEST_MISMATCH_TEST=FAIL' >&2
exit 1
fi
echo "ROLLBACK_DIGEST_MISMATCH_TEST=PASS"
echo "ROLLBACK_REPEAT_TEST=PASS"
+63
View File
@@ -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',
}
+563
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
{
"version": "V1R4C5",
"codename": "C5 reliability-security-admin",
"status": "active-baseline",
"tag": "V1R4C5",
"tag_ref": "refs/tags/V1R4C5",
"tag_object": "ac9bdacb19ff66cba8046d75358866f0f0fffb82",
"previous_version": "V1R4C3",
"rollback_version": "V1R4C3",
"baseline": "V1R4C3",
"release_reason": "reliability, watcher cleanup, user data isolation gate, and admin workbench read-only preparation; memory search disabled",
"purpose": "stabilize autonomous operations, enforce user isolation gate, prepare admin workbench read-only for C6",
"changes": [
"reliability: idle container GC with nixos-activate fallback retention policy and CI reset",
"watcher: remove redundant local async watcher, consolidate to server-pushed events",
"security: user data isolation gate (read-only admin panel, fb isolation check)",
"admin workbench: read-only preparation layout for V1R4C6 admin-search integration",
"governance: final owner approval via Owner Gate Web UI"
],
"artifacts": [
"VERSION",
"docs/releases/V1R4C5-acceptance-evidence.md",
"docs/releases/V1R4C5-final-owner-approval.md"
],
"validated_actions": [],
"secret_plaintext_written": false,
"owner_approval_required": true,
"owner_notification_channels": ["wechat", "web_ui_audit"],
"constraints_original": {
"memory_search": "remain disabled",
"sr04": "disable read-only visibility slice if RBAC or isolation check fails",
"gitea": "enter autonomous safe mode if branch consistency drift detected"
},
"promoted_at": "2026-07-11T02:17:31+08:00",
"promoted_by": "openclaw/admin",
"created_at": "2026-07-11T01:40:00+08:00",
"author": "openclaw"
}
+111
View File
@@ -0,0 +1,111 @@
{
"version": "V1R4C6",
"codename": "admin-search-agent-integration",
"status": "release-candidate-started",
"base_baseline": "V1R4C5",
"created_at": "2026-07-11",
"created_by": "chatgpt-gitops-bot",
"release_type": "controlled-bundled-integration",
"promotion_policy": "owner-gated",
"owner_decision": {
"accepted_scope": true,
"requested_delivery": "admin workbench, SR-03, and Agent Platform Integration in one release train",
"memory_search_enabled": false,
"force_push_allowed": false
},
"scope": {
"included_groups": [
{
"id": "V1R4C6-A",
"name": "Admin Workbench and Portal AIOS Read-Only Rollout",
"branches": [
"feature/v1r4c4-sr04-admin-workbench-visibility-20260711",
"feature/v1r4c4-sr04-portal-aios-visibility-20260710",
"patch/user-portal-me-route-diagnostics-20260709",
"patch/user-portal-rbac-ops-dashboard",
"patch/agent-observability-webui",
"patch/web-ui-complete-gitops-dashboard-20260706"
],
"risk": "medium",
"delivery": "administrator-visible task progress, process diagrams, health, agent observability, and portal AIOS entry points",
"guardrails": [
"read-only first",
"RBAC enforced",
"empty states explicit",
"no cross-user or cross-project data exposure"
]
},
{
"id": "V1R4C6-B",
"name": "SR-03 Search Gateway Real Provider Binding",
"branches": [
"feature/v1r4c4-sr02-search-gateway-resilience-20260710",
"patch/search-gateway-governed-general-capability",
"patch/gitops-change-archive-audit"
],
"risk": "high",
"delivery": "real provider binding, governed search gateway, resilience, and audit trail",
"guardrails": [
"user data isolation gate required",
"scope filter before ranking",
"provenance required",
"no global memory/vector index"
]
},
{
"id": "V1R4C6-C",
"name": "Agent Platform Integration",
"branches": [
"patch/agent-event-bus-unified",
"patch/agent-gpt-message-plane",
"patch/autonomous-controller-dispatch-20260709",
"patch/self-evolution-agent-orchestration-policy",
"patch/aios-autonomous-orchestrator-architecture-review-v1"
],
"risk": "high",
"delivery": "agent event bus, GPT message plane, dispatch, orchestration policy, and architecture record",
"guardrails": [
"controller safe-mode respected",
"event/message scopes enforced",
"owner gate for consequential operations",
"observability before autonomous expansion"
]
}
],
"excluded": [
"memory search activation",
"global vector index",
"full cross-session recall",
"unscoped search over memory",
"bulk merge of test/smoke/auth branches",
"Gitea DB mutation",
"force-push reconciliation"
]
},
"acceptance_required": [
"getRuntimeStatus active",
"getBaseline reports V1R4C5 before integration and V1R4C6 after promotion",
"Admin workbench RBAC read-only check passes",
"Portal AIOS manifest/dashboard/health routes verified",
"Flow/process diagram renders or returns explicit empty state",
"Search gateway provider binding returns scoped, provenanced results only",
"User data isolation acceptance remains green",
"Agent event bus and GPT message plane tests pass",
"Autonomous controller dispatch respects safe mode",
"Memory search remains disabled"
],
"deployment_constraints": {
"AIOS_MEMORY_ENABLED": false,
"memory_search_activation_requires_future_owner_approval": true,
"force_push_allowed": false,
"gitea_db_mutation_requires_owner_admin_approval": true,
"admin_workbench_mode": "read_only_first",
"search_provider_scope": "scoped_only"
},
"rollback_policy": {
"admin_workbench": "disable new menu/routes if RBAC or route acceptance fails",
"search_gateway": "fallback to disabled provider binding, not memory search",
"agent_platform": "disable dispatch expansion and keep event/message plane read-only if safety tests fail",
"memory_search": "remain disabled"
}
}
@@ -0,0 +1,31 @@
# AIOS Central Orchestrator Workspace Agent
Contract commit: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
When an API-triggered input arrives:
1. Parse the AIOS event JSON from the trigger message.
2. Verify `contract_commit` exactly matches the configured contract commit.
3. Use connected GitOps tools to read the authoritative Git tree, runtime status,
gate state, test evidence and full OIDs.
4. Never invent evidence, credentials, secrets, tests, approvals, Git results,
model availability or deployment status.
5. Apply the adaptive dispatch contract:
- Prefer OpenClaw when quality is comparable, work is large/repetitive,
monitoring-heavy, infrastructure-heavy or cost-sensitive.
- Prefer the strongest actually available Codex model for complex code,
security boundaries, architecture and final high-risk review.
- On timeout/unavailability, mark the attempt `TIMEOUT` or `NOT_EXECUTED`
and assign the same contract to the fallback agent.
- Never infer PASS from timeout or missing output.
6. Every next work unit must include a clean base commit, allowed paths, required
checks, forbidden operations, primary agent and fallback agent.
7. Keep real credential, raw-wire, reviewer, secret-provider and owner-only
release gates fail-closed.
8. Submit exactly one strict decision through the
`submit_orchestration_decision` action.
9. Do not write a second decision for the same event ID.
10. Stop after the action succeeds.
The decision must satisfy the JSON schema in the connected action. Use the
incoming `event_id`, `project_key`, `contract_commit`, and `state_version`.
@@ -0,0 +1,114 @@
openapi: 3.1.0
info:
title: AIOS Orchestrator Decision Callback
version: 1.0.0
servers:
- url: https://REPLACE_WITH_GATEWAY_HOST
paths:
/v1/orchestrator/decisions:
post:
operationId: submit_orchestration_decision
summary: Submit one strict orchestration decision for a triggered event
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Decision'
responses:
'202':
description: Accepted
'409':
description: Contract, idempotency, or state version conflict
'422':
description: Schema validation failed
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
WorkUnit:
type: object
additionalProperties: false
required:
- id
- track_id
- gate_id
- primary_agent
- fallback_agent
- clean_base_commit
- allowed_paths
- required_checks
- forbidden_operations
- instruction
properties:
id: {type: string}
track_id: {type: string}
gate_id: {type: string}
primary_agent:
type: string
enum: [openclaw, codex]
fallback_agent:
type: string
enum: [openclaw, codex]
clean_base_commit:
type: string
pattern: '^[0-9a-f]{40}$'
allowed_paths:
type: array
items: {type: string}
required_checks:
type: array
items: {type: string}
forbidden_operations:
type: array
items: {type: string}
instruction: {type: string}
Decision:
type: object
additionalProperties: false
required:
- schema_version
- project_key
- contract_commit
- event_id
- expected_state_version
- verdict
- state_updates
- next_work_units
- requires_human_input
- stop_reason
properties:
schema_version:
type: integer
const: 1
project_key: {type: string}
contract_commit:
type: string
pattern: '^[0-9a-f]{40}$'
event_id: {type: string}
expected_state_version:
type: integer
minimum: 0
verdict:
type: string
enum: [DISPATCH, BLOCKED, COMPLETE, NOOP]
state_updates:
type: array
items:
type: object
additionalProperties: false
required: [key, value]
properties:
key: {type: string}
value: {type: string}
next_work_units:
type: array
items:
$ref: '#/components/schemas/WorkUnit'
requires_human_input: {type: boolean}
stop_reason:
type: [string, 'null']