ace-hitl-hermes 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4748399831bb81daa3c725bed83373df44675fa005410f22e88632073abe839a
4
- data.tar.gz: 63d3164afdfb14a1ff5072f553bc9a5c5f9e3a092ebb4088ab0822bf8522fddc
3
+ metadata.gz: 8f649adbf21f1152d0f999da753e27a78e33d2921fa815fc5cf1f5eded9cfd6d
4
+ data.tar.gz: affb9a379dbeaed8870ecf2a9be45764d29f1aec2301f6aa1bfba9edb028040e
5
5
  SHA512:
6
- metadata.gz: e6781892f412b2c1ba7798f2725a3c454f7c72844de9c81a68224add320e636539863fba1201bdb15d2753f878f5b932738b0fab1e34eaab8fc3e76a9a342cb7
7
- data.tar.gz: '07811266c2c80bf1277575bf6e295b522133ea4f06169cdface8d761b4c2ce9216ae65f22b127b8e3b552df58adc57a7e30be995cc5c82091237838a353e21d9'
6
+ metadata.gz: 60e474770c3ae4c8ae62bb6f68cbf7ac6675b0a93b800f5cf464e6f083c9c3f5b6bd365abd6e70b186a963dec494e39cecbc0f4feba7d5265b471170188f401f
7
+ data.tar.gz: 8c90e2e211d9d7d28168028210216c2fb50d5fb67af586bf5724259463d578ca30e80f7c68f732f4d48b990c0f4d4555df62e357dbcf44ce6a72361ae91009e9
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-10-08
11
+
12
+ ### Fixed
13
+
14
+ - Preserve the selected project for proposal operations and transport ingress instead of resolving a different ambient journal.
15
+
16
+ ## [0.2.0] - 2026-10-05
17
+
18
+ ### Added
19
+
20
+ - Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
21
+ - Installable correlated Telegram transport, explicit Captain/group registry, authenticated HITL IPC and guarded Hermes plugin assets.
22
+ - Durable non-secret submission acknowledgements, ingress receipts, polling offsets and conservative reconciliation checkpoints.
23
+ - Supervised single polling actor with gateway ownership checks and recovery without duplicate lifecycle effects.
24
+
25
+ ### Changed
26
+
27
+ - Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-hitl ~> 0.12`, `ace-hitl-contract ~> 0.2`.
28
+
29
+ - 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.
30
+ - Ordinary answer folder publication requires authenticated request classification; OTP and sensitive answers are refused before any file creation.
31
+
32
+ ### Fixed
33
+
34
+ - Block later proposal approval behind unresolved earlier ingress and reconcile canonical proposer wakes after the existing transport poll loop establishes coverage.
35
+ - Submit authenticated requests from users named captain; leave unmanaged instructions for their target based on lifecycle authority, not sender labels.
36
+ - Retain the continuous polling owner across transient pending-publication transport outages, report the channel failure and retry without consuming the request.
37
+
10
38
  ## [0.1.0] - 2026-09-27
11
39
 
12
40
  ### 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
- (spec `8wm.t.vs1`). The shared folder between the lab and hermes **is**
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 (labd pushes answers to the asking agent,
11
- hermes surfaces questions to the Captain);
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` (spec `8wm.t.vs1`).
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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ace/hitl/hermes/cli"
5
+ trap("INT") { exit 130 }
6
+ begin
7
+ Ace::Hitl::Hermes::CLI.start(ARGV)
8
+ rescue Ace::Support::Cli::Error => e
9
+ warn e.message
10
+ exit e.exit_code
11
+ end
@@ -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,147 @@
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
+ @registry.channels.flat_map { |channel| channel.fetch("projects") }.each do |project|
125
+ after = nil
126
+ loop do
127
+ page = @lifecycle.proposal_due(project: project, after: after)
128
+ page.fetch("items").each do |proposal|
129
+ @relay.reconcile(request: proposal.fetch("request_id"), through: proposal.fetch("deadline"))
130
+ end
131
+ cursor = page.fetch("next")
132
+ break unless cursor
133
+ raise ContractError, "proposal deadline cursor did not advance" unless cursor.is_a?(String) && (!after || cursor > after)
134
+ after = cursor
135
+ end
136
+ end
137
+ rescue Ace::Hitl::Lifecycle::Error, ContractError => e
138
+ warn "ace-hitl-hermes: proposal reconciliation unavailable (#{e.class}); deadlines deferred"
139
+ end
140
+
141
+ def telegram
142
+ @telegram ||= Transport::Telegram.new(token_file: @config.fetch("token_file"))
143
+ end
144
+ end
145
+ end
146
+ end
147
+ 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