ace-hitl-hermes 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +21 -4
- data/docs/usage.md +89 -0
- data/exe/ace-hitl-hermes +11 -0
- data/lib/ace/hitl/hermes/cli.rb +68 -0
- data/lib/ace/hitl/hermes/organisms/hermes_box.rb +12 -1
- data/lib/ace/hitl/hermes/runtime.rb +145 -0
- data/lib/ace/hitl/hermes/transport/journal.rb +85 -0
- data/lib/ace/hitl/hermes/transport/poller.rb +103 -0
- data/lib/ace/hitl/hermes/transport/registry.rb +71 -0
- data/lib/ace/hitl/hermes/transport/relay.rb +362 -0
- data/lib/ace/hitl/hermes/transport/telegram.rb +61 -0
- data/lib/ace/hitl/hermes/version.rb +1 -1
- data/lib/ace/hitl/hermes.rb +5 -0
- data/plugin/__init__.py +62 -0
- data/plugin/plugin.yaml +4 -0
- metadata +71 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cea9b0aa8245c024ee39a1aa4a63f55e7513c2d906f5648b3e19f2d70ac8b7b4
|
|
4
|
+
data.tar.gz: aa9c46cdc21476780ce55d9ab63b7b371c2b454a4e1c08ae22a5a0ec93ec11e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d33d3b81a50402c434dd91e62739cd48ff35ae2a807ab937120b51a968dd15a6e0a11d59542565a5fa6dd3db57b98ddcc42198772018aea6ce91d4b1a9082d4
|
|
7
|
+
data.tar.gz: 32b5352923b92216718c6b5667c3e773a36f108327300427424a5f4f2bf82b5f09fad1849aec1f64c8947594a75e9a3b131cb8b1903664b57a8654a494f10d20
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
|
|
15
|
+
- Installable correlated Telegram transport, explicit Captain/group registry, authenticated HITL IPC and guarded Hermes plugin assets.
|
|
16
|
+
- Durable non-secret submission acknowledgements, ingress receipts, polling offsets and conservative reconciliation checkpoints.
|
|
17
|
+
- Supervised single polling actor with gateway ownership checks and recovery without duplicate lifecycle effects.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-hitl ~> 0.12`, `ace-hitl-contract ~> 0.2`.
|
|
22
|
+
|
|
23
|
+
- Consume the shared managed binding envelope and publish newly created authenticated pending requests through explicitly registered project channels in the existing single Telegram polling actor.
|
|
24
|
+
- Ordinary answer folder publication requires authenticated request classification; OTP and sensitive answers are refused before any file creation.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Block later proposal approval behind unresolved earlier ingress and reconcile canonical proposer wakes after the existing transport poll loop establishes coverage.
|
|
29
|
+
- Submit authenticated requests from users named captain; leave unmanaged instructions for their target based on lifecycle authority, not sender labels.
|
|
30
|
+
- Retain the continuous polling owner across transient pending-publication transport outages, report the channel failure and retry without consuming the request.
|
|
31
|
+
|
|
10
32
|
## [0.1.0] - 2026-09-27
|
|
11
33
|
|
|
12
34
|
### Added
|
data/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# ace-hitl-hermes
|
|
2
2
|
|
|
3
3
|
Folder-as-interface HITL transport plugin for the hermes relay
|
|
4
|
-
(
|
|
4
|
+
(specs `8wm.t.vs1` and `8wm.t.y24`). The shared folder between the lab and hermes **is**
|
|
5
5
|
the transport:
|
|
6
6
|
|
|
7
7
|
- a **message** is a file `<id>.json` in the channel folder;
|
|
8
8
|
- the **address** is `<machine>/<folder>/<id>`;
|
|
9
9
|
- **delivery** is push — the consuming side picks the validated file up
|
|
10
|
-
and takes it to its target (
|
|
11
|
-
|
|
10
|
+
and takes it to its target (the explicit HITL live client queues ordinary
|
|
11
|
+
answers to the exact native owner; Hermes surfaces questions to the Captain);
|
|
12
12
|
- **ACK** is the deletion of the file after delivery.
|
|
13
13
|
|
|
14
14
|
The Captain's answer is a file `<folder>/<id>.json` with the fields
|
|
@@ -17,7 +17,7 @@ tmp file + rename) without root.
|
|
|
17
17
|
|
|
18
18
|
## Contract
|
|
19
19
|
|
|
20
|
-
- Folder contract: `ace.hitl.hermes.folder/v1` (
|
|
20
|
+
- Folder contract: `ace.hitl.hermes.folder/v1` (specs `8wm.t.vs1` and `8wm.t.y24`).
|
|
21
21
|
- Message schema: `ace.hitl.hermes.message/v1`; the machine-readable
|
|
22
22
|
JSON Schema ships at
|
|
23
23
|
`lib/ace/hitl/hermes/schemas/message.v1.schema.json`.
|
|
@@ -53,6 +53,7 @@ registry.default = "inbox"
|
|
|
53
53
|
lines = []
|
|
54
54
|
box = Ace::Hitl::Hermes::Organisms::HermesBox.new(
|
|
55
55
|
channel: "inbox", registry: registry,
|
|
56
|
+
answer_authorizer: ->(id) { authenticated_hitl_client.read(id) },
|
|
56
57
|
notifier: ->(line) { lines << line }
|
|
57
58
|
)
|
|
58
59
|
|
|
@@ -70,9 +71,25 @@ result.messages.each do |msg|
|
|
|
70
71
|
end
|
|
71
72
|
```
|
|
72
73
|
|
|
74
|
+
## Telegram transport
|
|
75
|
+
|
|
76
|
+
The installed `ace-hitl-hermes serve` actor owns exact registered-group correlation,
|
|
77
|
+
submission acknowledgements, durable ingress sequencing and conservative checkpoints.
|
|
78
|
+
OTP goes directly through authenticated `ace-hitl` IPC; sensitive folder answers are
|
|
79
|
+
rejected before publication. Ordinary answers require authenticated request classification.
|
|
80
|
+
See [configuration, single polling owner and recovery](docs/usage.md).
|
|
81
|
+
|
|
73
82
|
## Development
|
|
74
83
|
|
|
75
84
|
```sh
|
|
76
85
|
ace-test atoms # fast unit tests
|
|
77
86
|
ace-test # the whole package suite
|
|
78
87
|
```
|
|
88
|
+
|
|
89
|
+
The installed `serve` actor also publishes newly created scoped requests from
|
|
90
|
+
authenticated `pending(project:)` into its registered folder channel before
|
|
91
|
+
submitting questions. Ask needs no manual post or hidden Lab watcher. Projects
|
|
92
|
+
without a registered authorized channel remain pending. Folder identity/body
|
|
93
|
+
conflicts refuse publication; repeated runs do not send the same submitted
|
|
94
|
+
question again. This actor remains the sole Telegram polling owner described
|
|
95
|
+
above. Actual installed Telegram acceptance is a separate deployment gate.
|
data/docs/usage.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Correlated Hermes Telegram transport
|
|
2
|
+
|
|
3
|
+
Install `ace-hitl-hermes` and configure the supervised `ace-hitl-hermes serve` actor as the only Telegram polling owner. Lab provides identities and deployment paths; the package owns routing, folder publication, protected IPC, correlation and checkpoint production.
|
|
4
|
+
|
|
5
|
+
## Configuration
|
|
6
|
+
|
|
7
|
+
A registry has no inferred default or legacy-file fallback:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"schema": "ace.hitl.hermes.channels/v1",
|
|
12
|
+
"channels": [{
|
|
13
|
+
"name": "project", "machine": "lab", "folder": "/run/hermes/project",
|
|
14
|
+
"target": "project-overseer", "projects": ["project"], "chat_id": "-1001234567890",
|
|
15
|
+
"captain_user_ids": ["123456789"]
|
|
16
|
+
}]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Every project, group, folder and instruction target maps to exactly one channel. Captain IDs are numeric identities, never usernames. Topics within a group share that registered channel; separate authority requires separate registered groups.
|
|
21
|
+
|
|
22
|
+
A runtime file supplies concrete deployment configuration:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"schema": "ace.hitl.hermes.runtime/v1",
|
|
27
|
+
"registry": "/etc/ace-hitl-hermes/channels.json",
|
|
28
|
+
"state": "/var/lib/ace-hitl-hermes",
|
|
29
|
+
"hitl_socket": "/run/ace-hitl/lifecycle.sock",
|
|
30
|
+
"hitl_service_uid": 0,
|
|
31
|
+
"token_file": "/run/hermes-secrets/telegram-bot-token",
|
|
32
|
+
"polling_owner": "ace-hitl-hermes",
|
|
33
|
+
"hermes_gateway_config": "/home/hermes/.hermes/gateway.yaml"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run the actor as the unprivileged transport identity authorized by the `ace-hitl` service. The state directory and token file must belong to that identity and be private (0700/0600). Folder writes reject root and preserve the existing message.v1 0640 contract. `ace-hitl` authenticates both Unix socket peers and independently enforces project/actor authorization, liveness, OTP scope and expiry.
|
|
38
|
+
|
|
39
|
+
Before starting the actor, disable Telegram in the actual Hermes gateway configuration:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
platforms:
|
|
43
|
+
telegram:
|
|
44
|
+
enabled: false
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Stop/restart the existing gateway to apply that change. `serve` validates this configuration and refuses an enabled or unverifiable Telegram gateway. Its state-directory polling lease excludes a second package actor. During a transient HITL pending-publication transport outage, continuous `serve` reports the unavailable channel on stderr, waits one second, keeps its polling lease and retries on the next cycle without consuming or replacing the request. A single-pass run reports the error to its caller. A Telegram 409/conflicting poll failure invalidates ingress coverage rather than pretending it is connected. Never run the package actor and Hermes gateway against the same bot at the same time.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
ace-hitl-hermes serve --config /etc/ace-hitl-hermes/runtime.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The actor sends non-Captain question files only after authenticating their corresponding lifecycle request. It uses the lifecycle attempt as the immutable transport revision. Plain Captain instructions remain question files with sender `captain`, routed to the folder's configured target. The target consumes those files using the existing folder contract and ACKs by deletion.
|
|
54
|
+
|
|
55
|
+
## Replies and recovery
|
|
56
|
+
|
|
57
|
+
Use Telegram Reply to the exact submitted request message, or `/hitl-reply REQUEST_ID ANSWER`. The command names an immutable request correlation in the same registered group; a conflicting Reply target is rejected. Plain messages create new instructions and never satisfy a pending request. Unknown Reply, missing registry, bad sender and malformed input fail closed.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
ace-hitl-hermes delivery --request REQUEST_ID --config /etc/ace-hitl-hermes/runtime.json
|
|
61
|
+
ace-hitl-hermes ingress reconcile --request REQUEST_ID --through 2026-10-04T22:00:00Z --format json --config /etc/ace-hitl-hermes/runtime.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `ace.hitl.hermes.delivery/v1` result carries request/revision/channel/chat/message identities, status and trusted `submitted_at` only after Telegram confirms submission. Submission is not a read receipt. `failed` preserves the question and may retry; `uncertain` never automatically resends and starts no decision clock. Investigate uncertain sends before taking any recovery action.
|
|
65
|
+
|
|
66
|
+
The `ace.hitl.hermes.ingress-checkpoint/v1` result carries `healthy`, `drained`, checkpoint cutoff/sequence and unresolved correlated ingress metadata. Receipt time is stamped by the actor, not copied from Telegram. The actor journals receipt before delivery and advances its durable Telegram offset after processing. Only an empty update batch proves backlog drainage; poll failures, retention gaps, queued replies, uncertain submission and unresolved delivery produce unknown or undrained results. Each coverage break creates a durable generation and retains the prior epoch evidence. A recovered empty poll starts healthy coverage for fresh requests; requests submitted in an older generation remain unknown and cannot gain silence approval across the gap. A silence-based approval consumer must defer on those results and revalidate the checkpoint inside its decision transition.
|
|
67
|
+
|
|
68
|
+
The private journal retains correlation, ingress and terminal transport metadata for at least 24 hours. Authoritative lifecycle receipts remain in `ace-hitl` beyond that window. A resumed ordinary reply recovers from its protected folder answer; the lifecycle service prevents a repeated effect. Unresolved records remain visible and block drainage.
|
|
69
|
+
|
|
70
|
+
OTP answers bypass ordinary answer files. The low-level `HermesBox` answer publisher requires an authenticated request-classification callback and rejects OTP/sensitive classification before any file creation. The relay sends the value directly to `ace-hitl` authenticated IPC and records only a sanitized status. No OTP value or derived hash enters the journal, checkpoint, preview logs or target instruction folder. If the endpoint is unavailable or the actor crashes after journaling a secret receipt, `secret-unavailable` discards the transient value and disables further delivery on that original challenge; recover the endpoint and request a fresh authorized challenge/code. Telegram's third-party message history is outside this erasure guarantee.
|
|
71
|
+
|
|
72
|
+
## Gateway guard plugin
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
ace-hitl-hermes plugin install --path /home/hermes/.hermes/plugins/ace-hitl-hermes
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Configure the gateway process with `ACE_HITL_HERMES_CONFIG` pointing to the runtime file, and put the installed executable on PATH (or set `ACE_HITL_HERMES_EXE` to its absolute path). The guard registers `/hitl-reply` and `pre_gateway_dispatch`, intercepting replies before inbound preview logging even when IPC/configuration fails. Its subprocess sends input through stdin and suppresses child output. The guard alone does not prove polling drainage; the supervised actor is the checkpoint producer. Package polling ownership still requires disabling the gateway's Telegram adapter.
|
|
79
|
+
|
|
80
|
+
## Exit codes and acceptance
|
|
81
|
+
|
|
82
|
+
Successful commands return 0 and JSON. Rejected input/configuration returns 1 with a sanitized error. SIGINT returns 130. OTP is never accepted as an argument: `receive` takes a bounded JSON event from stdin.
|
|
83
|
+
|
|
84
|
+
Local acceptance exercises registered controlled identities, real message folders, fsynced state, a real authenticated Unix socket and installed guard assets. Live Telegram channel acceptance and the Lab transport smoke/removal of `hermes-lab-hitl`, `lab-hitl-broker` and `lab-hitl-channels` remain explicit `lab-config:gad.2` delivery gates; local tests do not claim those deployment results.
|
|
85
|
+
|
|
86
|
+
A lifecycle requester named `captain` is handled like every other authenticated
|
|
87
|
+
requester. Submission authority comes from the scoped lifecycle record and its
|
|
88
|
+
exact project/body binding, not the folder sender label. Unmanaged Captain
|
|
89
|
+
instructions remain in the folder for their target and are not sent as requests.
|
data/exe/ace-hitl-hermes
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "runtime"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Hitl
|
|
10
|
+
module Hermes
|
|
11
|
+
module CLI
|
|
12
|
+
def self.start(argv, input: $stdin, output: $stdout)
|
|
13
|
+
args = argv.dup
|
|
14
|
+
operation = args.shift
|
|
15
|
+
if [nil, "--help", "-h", "help"].include?(operation)
|
|
16
|
+
output.puts("ace-hitl-hermes serve|submit|receive|delivery|ingress reconcile|plugin install --config FILE")
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
if %w[--version version].include?(operation)
|
|
20
|
+
output.puts(Ace::Hitl::Hermes::VERSION)
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
operation = "reconcile" if operation == "ingress" && args.shift == "reconcile"
|
|
24
|
+
operation = "install" if operation == "plugin" && args.shift == "install"
|
|
25
|
+
options = {format: "json"}
|
|
26
|
+
parser = OptionParser.new do |o|
|
|
27
|
+
o.banner = "ace-hitl-hermes serve|submit|receive|delivery|ingress reconcile|plugin install [options]"
|
|
28
|
+
%w[config request revision channel through format path].each do |key|
|
|
29
|
+
o.on("--#{key} VALUE") { |value| options[key.to_sym] = value }
|
|
30
|
+
end
|
|
31
|
+
o.on("--once") { options[:once] = true }
|
|
32
|
+
o.on("--help") { output.puts(o); return }
|
|
33
|
+
end
|
|
34
|
+
parser.parse!(args)
|
|
35
|
+
raise ContractError, "unexpected arguments" unless args.empty?
|
|
36
|
+
raise ContractError, "only --format json is supported" unless options[:format] == "json"
|
|
37
|
+
if operation == "install"
|
|
38
|
+
destination = options.fetch(:path)
|
|
39
|
+
raise ContractError, "plugin installation requires an absolute path" unless File.absolute_path?(destination)
|
|
40
|
+
raise ContractError, "plugin destination already exists" if File.exist?(destination)
|
|
41
|
+
FileUtils.cp_r(File.expand_path("../../../../plugin", __dir__), destination)
|
|
42
|
+
output.puts(JSON.generate({"installed" => true, "path" => destination}))
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
runtime = Runtime.new(options.fetch(:config))
|
|
46
|
+
result = case operation
|
|
47
|
+
when "serve" then runtime.serve(once: options[:once]); {"status" => "stopped"}
|
|
48
|
+
when "submit"
|
|
49
|
+
runtime.relay.submit(channel: options.fetch(:channel), request: options.fetch(:request),
|
|
50
|
+
revision: options.fetch(:revision))
|
|
51
|
+
when "receive"
|
|
52
|
+
bytes = input.read(16 * 1024 + 1)
|
|
53
|
+
raise ContractError, "ingress frame too large" if bytes.bytesize > 16 * 1024
|
|
54
|
+
runtime.relay.receive(JSON.parse(bytes))
|
|
55
|
+
when "delivery" then runtime.relay.delivery(options.fetch(:request))
|
|
56
|
+
when "reconcile"
|
|
57
|
+
runtime.relay.reconcile(request: options.fetch(:request), through: options.fetch(:through))
|
|
58
|
+
else raise ContractError, "choose serve, submit, receive, delivery, ingress reconcile, or plugin install"
|
|
59
|
+
end
|
|
60
|
+
output.puts(JSON.generate(result))
|
|
61
|
+
rescue ContractError, JSON::ParserError, KeyError, OptionParser::ParseError, SystemCallError
|
|
62
|
+
# No exception detail: a malformed frame or HTTP error may contain a secret.
|
|
63
|
+
raise Ace::Support::Cli::Error, "Hermes operation rejected; verify configuration and non-secret delivery status"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -27,7 +27,7 @@ module Ace
|
|
|
27
27
|
attr_reader :channel
|
|
28
28
|
|
|
29
29
|
def initialize(channel:, registry: nil, id_generator: -> { SecureRandom.hex(6) },
|
|
30
|
-
notifier: nil, euid_provider: -> { Process.euid })
|
|
30
|
+
notifier: nil, euid_provider: -> { Process.euid }, answer_authorizer: nil)
|
|
31
31
|
@channel =
|
|
32
32
|
if channel.is_a?(Molecules::HermesChannels::Channel)
|
|
33
33
|
channel
|
|
@@ -40,6 +40,7 @@ module Ace
|
|
|
40
40
|
@id_generator = id_generator
|
|
41
41
|
@notifier = notifier || ->(_line) {}
|
|
42
42
|
@euid_provider = euid_provider
|
|
43
|
+
@answer_authorizer = answer_authorizer
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def address(id)
|
|
@@ -53,6 +54,16 @@ module Ace
|
|
|
53
54
|
# so a collision there fails loudly instead of silently breaking
|
|
54
55
|
# the question -> answer pairing. Returns the published Message.
|
|
55
56
|
def publish(kind:, body:, sender:, timestamp:, id: nil)
|
|
57
|
+
if kind.to_s == "answer"
|
|
58
|
+
unless id && @answer_authorizer
|
|
59
|
+
raise ContractError, "answer publication requires authenticated request classification"
|
|
60
|
+
end
|
|
61
|
+
facts = @answer_authorizer.call(id)
|
|
62
|
+
unless facts.is_a?(Hash) && facts["id"] == id && facts["sensitive"] == false &&
|
|
63
|
+
!%w[otp secret].include?(facts["kind"])
|
|
64
|
+
raise ContractError, "sensitive answers must use the protected HITL boundary"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
56
67
|
explicit_id = !id.nil?
|
|
57
68
|
attempts = 0
|
|
58
69
|
loop do
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "ace/hitl/lifecycle"
|
|
5
|
+
require_relative "../hermes"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module Hermes
|
|
10
|
+
class Runtime
|
|
11
|
+
attr_reader :registry, :journal, :relay, :config
|
|
12
|
+
|
|
13
|
+
def initialize(path, clock: -> { Time.now.utc })
|
|
14
|
+
@clock = clock
|
|
15
|
+
@config = JSON.parse(File.read(path))
|
|
16
|
+
unless @config.is_a?(Hash) && @config["schema"] == "ace.hitl.hermes.runtime/v1"
|
|
17
|
+
raise ContractError, "runtime configuration requires runtime/v1"
|
|
18
|
+
end
|
|
19
|
+
@registry = Transport::Registry.load(@config.fetch("registry"))
|
|
20
|
+
@journal = Transport::Journal.new(@config.fetch("state"))
|
|
21
|
+
@lifecycle = Ace::Hitl::Lifecycle::Client.new(
|
|
22
|
+
socket_path: @config.fetch("hitl_socket"), service_uid: @config.fetch("hitl_service_uid")
|
|
23
|
+
)
|
|
24
|
+
@relay = Transport::Relay.new(registry: @registry, journal: @journal, lifecycle: @lifecycle,
|
|
25
|
+
sender: ->(channel, question) { telegram.call(channel, question) }, clock: @clock)
|
|
26
|
+
rescue JSON::ParserError, KeyError, SystemCallError, ArgumentError
|
|
27
|
+
raise ContractError, "runtime configuration is unavailable or malformed"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def serve(once: false)
|
|
31
|
+
verify_polling_owner!
|
|
32
|
+
@journal.actor do
|
|
33
|
+
poller = Transport::Poller.new(relay: @relay, telegram: telegram, journal: @journal,
|
|
34
|
+
registry: @registry, clock: @clock)
|
|
35
|
+
# Establish the new coverage epoch before issuing questions, so
|
|
36
|
+
# a startup submission is never stamped before its coverage begins.
|
|
37
|
+
begin
|
|
38
|
+
poller.once
|
|
39
|
+
rescue ContractError
|
|
40
|
+
raise if once
|
|
41
|
+
end
|
|
42
|
+
loop do
|
|
43
|
+
# Only an authoritative lifecycle request may be submitted.
|
|
44
|
+
# A sender label is not authority or a reserved OS username.
|
|
45
|
+
@registry.channels.each do |channel|
|
|
46
|
+
box_channel = Molecules::HermesChannels::Channel.new(
|
|
47
|
+
name: channel["name"], machine: channel["machine"], folder: channel["folder"]
|
|
48
|
+
)
|
|
49
|
+
box = Organisms::HermesBox.new(channel: box_channel)
|
|
50
|
+
begin
|
|
51
|
+
publish_pending(channel, box)
|
|
52
|
+
rescue Ace::Hitl::Lifecycle::TransportError => e
|
|
53
|
+
raise if once
|
|
54
|
+
warn "ace-hitl-hermes: pending publication unavailable for #{channel['name']} (#{e.class}); retrying"
|
|
55
|
+
sleep 1
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
box.poll.messages.each do |message|
|
|
59
|
+
next unless message.question?
|
|
60
|
+
begin
|
|
61
|
+
facts = @lifecycle.read(message.id)
|
|
62
|
+
revision = facts["proposal"] ? facts.fetch("proposal").fetch("revision_id") : facts.fetch("attempt")
|
|
63
|
+
@relay.submit(channel: channel["name"], request: message.id, revision: revision)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
# Pending folder + visible delivery status are retained.
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
begin
|
|
70
|
+
poller.once
|
|
71
|
+
rescue ContractError
|
|
72
|
+
raise if once
|
|
73
|
+
sleep 1
|
|
74
|
+
end
|
|
75
|
+
reconcile_proposals
|
|
76
|
+
break if once
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def verify_polling_owner!
|
|
82
|
+
unless @config["polling_owner"] == "ace-hitl-hermes"
|
|
83
|
+
raise ContractError, "serve requires explicit polling_owner ace-hitl-hermes"
|
|
84
|
+
end
|
|
85
|
+
path = @config.fetch("hermes_gateway_config")
|
|
86
|
+
gateway = YAML.safe_load_file(path, aliases: false)
|
|
87
|
+
unless gateway.is_a?(Hash) && gateway.dig("platforms", "telegram", "enabled") == false
|
|
88
|
+
raise ContractError, "disable Telegram in the configured Hermes gateway before starting serve"
|
|
89
|
+
end
|
|
90
|
+
rescue KeyError, SystemCallError, Psych::Exception
|
|
91
|
+
raise ContractError, "cannot verify Hermes gateway Telegram polling is disabled"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# The installed transport owns publication as well as Telegram polling.
|
|
97
|
+
# Ask writes only scoped lifecycle state; no hidden Lab watcher or
|
|
98
|
+
# HITL→Hermes dependency is required to get a question into message.v1.
|
|
99
|
+
def publish_pending(channel, box)
|
|
100
|
+
channel["projects"].each do |project|
|
|
101
|
+
@lifecycle.pending(project: project).each do |facts|
|
|
102
|
+
next unless facts["state"] == "created"
|
|
103
|
+
envelope = Ace::Hitl::Contract::ManagedEnvelope.load(facts.fetch("envelope"), expected: {
|
|
104
|
+
request_id: facts["id"], project: project, assignment_id: facts["assignment"], attempt_id: facts["attempt"]
|
|
105
|
+
})
|
|
106
|
+
status = @relay.delivery(facts["id"])["status"]
|
|
107
|
+
next unless %w[unknown failed].include?(status)
|
|
108
|
+
existing = box.poll.messages.find { |message| message.id == facts["id"] }
|
|
109
|
+
if existing
|
|
110
|
+
unless existing.question? && existing.body == facts["question"]
|
|
111
|
+
raise ContractError, "managed question folder identity conflicts"
|
|
112
|
+
end
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
box.publish(kind: :question, id: facts["id"], body: facts["question"], sender: envelope["requester"],
|
|
116
|
+
timestamp: Time.at(Integer(facts["created_at"])).utc.iso8601)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
rescue Ace::Hitl::Contract::InvalidEnvelope, KeyError, ArgumentError => e
|
|
120
|
+
raise ContractError, "managed pending publication binding is invalid (#{e.class})"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reconcile_proposals
|
|
124
|
+
after = nil
|
|
125
|
+
loop do
|
|
126
|
+
page = @lifecycle.proposal_due(after: after)
|
|
127
|
+
page.fetch("items").each do |proposal|
|
|
128
|
+
@relay.reconcile(request: proposal.fetch("request_id"), through: proposal.fetch("deadline"))
|
|
129
|
+
end
|
|
130
|
+
cursor = page.fetch("next")
|
|
131
|
+
break unless cursor
|
|
132
|
+
raise ContractError, "proposal deadline cursor did not advance" unless cursor.is_a?(String) && (!after || cursor > after)
|
|
133
|
+
after = cursor
|
|
134
|
+
end
|
|
135
|
+
rescue Ace::Hitl::Lifecycle::Error, ContractError => e
|
|
136
|
+
warn "ace-hitl-hermes: proposal reconciliation unavailable (#{e.class}); deadlines deferred"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def telegram
|
|
140
|
+
@telegram ||= Transport::Telegram.new(token_file: @config.fetch("token_file"))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Hitl
|
|
9
|
+
module Hermes
|
|
10
|
+
module Transport
|
|
11
|
+
# Single lock serializes send/receive/checkpoint with durable fsync commits.
|
|
12
|
+
# No message bodies or secret-derived digests belong in this journal.
|
|
13
|
+
class Journal
|
|
14
|
+
def initialize(directory)
|
|
15
|
+
@directory = File.expand_path(directory)
|
|
16
|
+
FileUtils.mkdir_p(@directory, mode: 0o700)
|
|
17
|
+
stat = File.lstat(@directory)
|
|
18
|
+
unless stat.directory? && !stat.symlink? && stat.uid == Process.euid && (stat.mode & 0o077).zero?
|
|
19
|
+
raise ContractError, "transport state requires an owned private directory"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def synchronize
|
|
24
|
+
path = File.join(@directory, "journal.lock")
|
|
25
|
+
File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
26
|
+
lock.flock(File::LOCK_EX)
|
|
27
|
+
state = read
|
|
28
|
+
yield state, -> { write(state) }
|
|
29
|
+
ensure
|
|
30
|
+
lock.flock(File::LOCK_UN)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def actor
|
|
35
|
+
path = File.join(@directory, "poller.lock")
|
|
36
|
+
File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
37
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
38
|
+
raise ContractError, "another Hermes polling actor owns this journal"
|
|
39
|
+
end
|
|
40
|
+
yield
|
|
41
|
+
ensure
|
|
42
|
+
lock.flock(File::LOCK_UN)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def read
|
|
49
|
+
path = File.join(@directory, "journal.json")
|
|
50
|
+
return {"schema" => "ace.hitl.hermes.transport/v1", "requests" => {}, "ingress" => [],
|
|
51
|
+
"sequence" => 0, "poll" => {}} unless File.exist?(path)
|
|
52
|
+
|
|
53
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
54
|
+
stat = file.stat
|
|
55
|
+
unless stat.file? && stat.uid == Process.euid && (stat.mode & 0o077).zero?
|
|
56
|
+
raise ContractError, "transport journal must be owned and private"
|
|
57
|
+
end
|
|
58
|
+
state = JSON.parse(file.read)
|
|
59
|
+
unless state["schema"] == "ace.hitl.hermes.transport/v1" && state["requests"].is_a?(Hash) &&
|
|
60
|
+
state["ingress"].is_a?(Array) && state["sequence"].is_a?(Integer) && state["poll"].is_a?(Hash)
|
|
61
|
+
raise ContractError, "transport journal is malformed"
|
|
62
|
+
end
|
|
63
|
+
state
|
|
64
|
+
end
|
|
65
|
+
rescue JSON::ParserError, SystemCallError
|
|
66
|
+
raise ContractError, "transport journal is unreadable"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write(state)
|
|
70
|
+
temporary = File.join(@directory, ".journal-#{SecureRandom.hex(8)}")
|
|
71
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
72
|
+
file.write(JSON.generate(state))
|
|
73
|
+
file.flush
|
|
74
|
+
file.fsync
|
|
75
|
+
end
|
|
76
|
+
File.rename(temporary, File.join(@directory, "journal.json"))
|
|
77
|
+
File.open(@directory) { |directory| directory.fsync }
|
|
78
|
+
ensure
|
|
79
|
+
File.unlink(temporary) if temporary && File.exist?(temporary)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Hermes
|
|
8
|
+
module Transport
|
|
9
|
+
# The sole supervised polling actor owns both getUpdates offset and
|
|
10
|
+
# proof that a batch's ingress has been handled before confirmation.
|
|
11
|
+
class Poller
|
|
12
|
+
def initialize(relay:, telegram:, journal:, registry:, clock: -> { Time.now.utc })
|
|
13
|
+
@relay, @telegram, @journal, @registry, @clock = relay, telegram, journal, registry, clock
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def once
|
|
17
|
+
cursor = nil
|
|
18
|
+
@journal.synchronize do |state, _commit|
|
|
19
|
+
cursor = state["cursor"] || {"offset" => 0, "through" => stamp, "generation" => 0, "coverage_from" => stamp}
|
|
20
|
+
end
|
|
21
|
+
# A durable queued record means a prior actor died with its body
|
|
22
|
+
# transient. Replay from the unadvanced offset; secret failures
|
|
23
|
+
# remain status-only and require a fresh challenge.
|
|
24
|
+
started = stamp
|
|
25
|
+
if Time.iso8601(started) - Time.iso8601(cursor["through"]) >= 24 * 60 * 60
|
|
26
|
+
invalidate_coverage(started)
|
|
27
|
+
cursor["coverage_from"] = started
|
|
28
|
+
cursor["generation"] += 1
|
|
29
|
+
@journal.synchronize do |state, commit|
|
|
30
|
+
state["cursor"] = cursor
|
|
31
|
+
commit.call
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
updates = @telegram.updates(offset: cursor["offset"])
|
|
35
|
+
ids = updates.map { |u| u.is_a?(Hash) && u["update_id"] }
|
|
36
|
+
unless ids.all? { |id| id.is_a?(Integer) && id >= cursor["offset"] } && ids == ids.sort && ids.uniq == ids
|
|
37
|
+
raise ContractError, "malformed Telegram update sequence"
|
|
38
|
+
end
|
|
39
|
+
unresolved = false
|
|
40
|
+
updates.each do |update|
|
|
41
|
+
message = update["message"]
|
|
42
|
+
next unless message.is_a?(Hash)
|
|
43
|
+
chat, from = message["chat"], message["from"]
|
|
44
|
+
next unless chat.is_a?(Hash) && from.is_a?(Hash)
|
|
45
|
+
next unless @registry.channels.any? { |c| c["chat_id"] == chat["id"].to_s }
|
|
46
|
+
event = {"platform" => "telegram", "chat_id" => chat["id"].to_s,
|
|
47
|
+
"chat_type" => chat["type"], "user_id" => from["id"].to_s,
|
|
48
|
+
"message_id" => message["message_id"].to_s,
|
|
49
|
+
"reply_to_message_id" => message.dig("reply_to_message", "message_id").to_s,
|
|
50
|
+
"text" => message["text"]}
|
|
51
|
+
begin
|
|
52
|
+
result = @relay.receive(event)
|
|
53
|
+
unresolved ||= %w[queued unresolved].include?(result["status"])
|
|
54
|
+
rescue ContractError
|
|
55
|
+
# Authority and malformed correlation are rejected, never
|
|
56
|
+
# forwarded into an agent pipeline or preview log.
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
finished = stamp
|
|
60
|
+
continuous = Time.iso8601(started) - Time.iso8601(cursor["through"]) < 24 * 60 * 60
|
|
61
|
+
# The getUpdates backlog is exhausted only on an empty response.
|
|
62
|
+
# A nonempty batch may have more replies queued on Telegram.
|
|
63
|
+
if updates.empty?
|
|
64
|
+
@registry.channels.each do |channel|
|
|
65
|
+
@relay.poll_complete(channel: channel["name"], started_at: cursor["coverage_from"],
|
|
66
|
+
through: started, continuous: continuous)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
return updates.size if unresolved # retain Telegram replay until ordinary handling recovers
|
|
70
|
+
@journal.synchronize do |state, commit|
|
|
71
|
+
state["cursor"] = {"offset" => ids.empty? ? cursor["offset"] : ids.last + 1,
|
|
72
|
+
"through" => finished, "generation" => cursor["generation"], "coverage_from" => cursor["coverage_from"]}
|
|
73
|
+
commit.call
|
|
74
|
+
end
|
|
75
|
+
updates.size
|
|
76
|
+
rescue StandardError
|
|
77
|
+
at = stamp
|
|
78
|
+
invalidate_coverage(at)
|
|
79
|
+
@journal.synchronize do |state, commit|
|
|
80
|
+
previous = state["cursor"] || cursor || {"offset" => 0, "generation" => 0}
|
|
81
|
+
state["cursor"] = previous.merge("through" => at, "coverage_from" => at,
|
|
82
|
+
"generation" => previous.fetch("generation", 0) + 1)
|
|
83
|
+
commit.call
|
|
84
|
+
end
|
|
85
|
+
raise ContractError, "Telegram poll unavailable; ingress coverage is unknown"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def invalidate_coverage(at)
|
|
91
|
+
@registry.channels.each do |channel|
|
|
92
|
+
@relay.poll_complete(channel: channel["name"], started_at: at, through: at, continuous: false)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def stamp
|
|
97
|
+
@clock.call.utc.iso8601
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|