Repository navigation
feat(gateway): pace trigger replies in virtual time, add deadlines, and attribute tool calls - #196
Merged
earakely-scale merged 4 commits intoOct 14, 2026
Conversation
…nd attribute tool calls Every trigger reaction fired within seconds, nothing could react to a call that never came, and the env trajectory did not say who made a call. - An action's `delay` (an ISO-8601 duration, or a seeded exponential like `every`) waits that much virtual time before it runs, and an executor's `hours` defer its nl actions until its working hours open. Both follow the gateway clock's rate; while the clock is unarmed a delay is real seconds and hours do not apply. register_env_triggers takes `executor_hours`. - A one-shot time trigger's `when.unless` makes it a deadline: a matching call before it is due cancels it (new status `cancelled`). For an `after` trigger the window opens when its anchor fires. - Every gateway tool_call event records the caller's `role`, and calls an executor makes while realizing a fire record `caused_by`. The card declares `action_delay`, `executor_hours` and `unless`, and the step refuses an older gateway, which would ignore them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…le deadlines cleanly - A delay now counts virtual time on a clock stopwatch (Clock.elapsed), so it follows a rate change exactly, holds while the clock is frozen instead of dividing by zero, and doesn't count a re-arm's jump. It runs from its cause: a queued call's delay from that call, a due mark's from the mark, so a burst or a backlog no longer pushes each reply a full delay later. - Working hours are reckoned again after a re-arm and lifted by a clear, and release once the opening is reached. - An `after` deadline's window opens when its anchor starts firing, so a reply to the anchor's own action cancels it; a reply after the deadline is due comes too late. Cancelling a deadline also retires the time triggers anchored on it. - remove() cancels the trigger's fire, and a removed or cleared trigger's fire logs nothing more. caused_by numbers every fire, failed ones too. - state() marks a fire that is waiting, and prompt_agent's settle poll no longer waits one out. `unless: null` means none; a barrier is refused for an executor with hours, as for a delay; malformed hours.days is a 400. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e day in hours A batch of due marks now reckons every mark's delay start up front, so a re-arm during one mark's wait doesn't count toward the next. A working window's length is taken modulo a timedelta day instead of 24 * 60. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A harness that fast-forwards the clock by re-arming it, as a staged warm-up does, now brings pending delays due: Clock.elapsed() adds the jump of each re-arm forward. A re-arm back still counts nothing, so a reset never shortens a wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
earakely-scale
deleted the
edgararakelyan/trigger-latency-deadlines-attribution
branch
October 14, 2026 17:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every trigger reaction fired within seconds of the call that provoked it. Nothing could react to a call that never came. The env trajectory did not record who made a call, or which trigger it answered. Personas who answer on a human schedule need more from the engine. So do deadlines ("if nobody replies within an hour, escalate") and trajectories you can read causally.
What
Pacing in virtual time
delaywaits before the action runs. It is either an ISO-8601 duration ("PT2M") or a seeded exponential ({"dist": "exp", "mean": "PT10M", "seed": 7}), likeevery's.PT2Mat 60× is two real seconds, and it is real seconds while the clock is unarmed. It follows a rate change and holds while the clock is frozen. A re-arm forward counts its jump, so a harness that fast-forwards the clock brings pending replies due; a re-arm back counts nothing.hoursdefer its nl actions until they open. The shape is{"start": "09:00", "end": "17:00", "days": ["mon", …], "utc_offset": "-07:00"}, and the deferral emits adeferredevent withuntil.register_env_triggerstakesexecutor_hours: {agent_name: hours}. It rejects names that no action uses.GET /triggers/statemarks a fire that is waiting out a delay or hours (waiting). prompt_agent's settle poll no longer waits one out.Deadlines
when.unless: {tool, where?, roles?}makes it a deadline. A matching call before it is due cancels it: the new status iscancelled, with acancelledevent naming the call.aftertrigger, the window opens when its anchor starts firing. A reply to the anchor's own action counts; calls before the anchor don't.unlesson a non-time trigger or aneverytrigger is a 400.Attribution
tool_callevent records the caller'srole.caused_by: [{trigger_id, action_index, fire}]. This lists every fire in flight for that executor.firecounts the trigger's fires, failed ones included./step, and tools the gateway serves itself.Lifecycle
/triggers/removenow cancels the trigger's fire, so a reply waiting out its delay never lands. A removed or cleared trigger's fire logs nothing more.Compatibility
action_delay,executor_hoursandunless.Examples
One small example per new capability. Every example was run through this branch's engine, and the times are what it logged. Times are virtual, at −05:00.
1. Delay a reply:
delay{"id": "john-replies", "when": {"type": "action", "tool": "slack_send_message", "where": {"channel_id": {"equals": "D_JOHN"}}}, "actions": [{"type": "tool", "tool": "slack_send_message", "as": "john@example.com", "delay": "PT5M", "args": {"channel_id": "D_JOHN", "text": "Got it, I'll take a look."}}]}The solver DMs John at 10:00. The trigger logs
delayed(300 s), and John's reply goes out at 10:05. Any action type can carry adelay.2. A human-paced, repeatable delay: a seeded
delay{"id": "denise-answers", "when": {"type": "action", "tool": "slack_send_message", "repeat": true, "where": {"channel_id": {"equals": "D_DENISE"}}}, "actions": [{"type": "nl", "executor": "denise", "delay": {"dist": "exp", "mean": "PT4M", "seed": 7}, "instruction": "Read the solver's latest message in D_DENISE and reply to it."}]}The solver writes at 10:00, 10:10 and 10:20. Denise answers after 94 s, 39 s and 253 s. Those delays average 4 minutes, and the seed gives the same delays on every run.
3. Working hours:
executor_hours{"type": "register_env_triggers", "env_id": "workspace", "watch_roles": ["default"], "executor_hours": {"bob": {"start": "09:00", "end": "17:00", "days": ["mon", "tue", "wed", "thu", "fri"], "utc_offset": "-05:00"}}, "triggers": [{"id": "bob-reviews", "when": {"type": "action", "tool": "gdocs_create_document"}, "actions": [{"type": "nl", "executor": "bob", "instruction": "Review the solver's new doc and leave comments."}]}]}The solver creates a doc at 18:30 on a Wednesday. Bob's action logs
deferredwithuntilThursday 09:00, and runs then. Hours apply only while the clock is armed. Registering on the gateway directly, they go onexecutors.bob.hours.4. A deadline at a fixed time:
at+unless{"id": "ap-chases", "when": {"type": "time", "at": "2026-07-01T12:00:00-05:00", "unless": {"tool": "gmail_send_message", "where": {"to": {"regex": "ap@example.com"}}}}, "actions": [{"type": "tool", "tool": "gmail_send_message", "as": "ap@example.com", "args": {"to": "solver@example.com", "subject": "Still need your approvals"}}]}If the solver emails AP before noon (11:40 here), the trigger becomes
cancelledand nothing is sent. Otherwise AP's chaser goes out at noon. A reply after noon is too late.5. A deadline that starts from another trigger:
after+unless{"id": "denise-dm", "when": {"type": "time", "at": "PT10M"}, "actions": [{"type": "nl", "executor": "denise", "instruction": "DM the solver in D_DENISE asking for the Q3 numbers."}]}, {"id": "denise-nudges", "when": {"type": "time", "after": "denise-dm", "offset": "PT30M", "unless": {"tool": "slack_send_message", "where": {"channel_id": {"equals": "D_DENISE"}}}}, "actions": [{"type": "nl", "executor": "denise", "instruction": "Nudge the solver: you still need the Q3 numbers."}]}Denise's DM at 10:10 opens a 30-minute window. A solver message in D_DENISE at 10:25 cancels the nudge, and one sent before her DM doesn't count. With no reply, she nudges at 10:40. The window opens as soon as the DM starts firing, so a reply that beats Denise's executor reporting back still counts.
6. Cancelling a chain
{"id": "bob-escalates", "when": {"type": "time", "after": "denise-nudges", "offset": "PT1H"}, "actions": [{"type": "nl", "executor": "bob", "instruction": "Escalate in #finance: the Q3 numbers are overdue."}]}Add this to example 5. The 10:25 reply now cancels both triggers, and
bob-escalateslogscancelledwithanchor: denise-nudges, because it can no longer fire. Without that reply, Bob escalates an hour after the nudge.7. A deadline only certain roles can answer:
unless.roles{"id": "chase-bob", "when": {"type": "time", "at": "PT2H", "unless": {"tool": "approve_bill", "roles": ["bob@example.com"]}}, "actions": [{"type": "nl", "executor": "denise", "instruction": "Ping Bob: the bill still needs his approval."}]}The solver's own
approve_billat 10:30 doesn't count; Bob's at 11:00 cancels the chaser. Withoutroles, the watched roles (the solver) answer a deadline.8. Who made each call:
roleandcaused_byNothing to configure. Every
tool_callin the env trajectory now records its caller. Calls an executor makes while it is acting on a trigger also record which trigger fire they serve:{"event_type": "tool_call", "role": "default", "tool_call": {"function_name": "slack_send_message"}} {"event_type": "tool_call", "role": "denise@example.com", "tool_call": {"function_name": "slack_send_message"}, "caused_by": [{"trigger_id": "denise-answers", "action_index": 0, "fire": 2}]}All together: a DM, a nudge and an escalation
{ "type": "register_env_triggers", "env_id": "workspace", "watch_roles": ["default"], "executor_hours": {"bob": {"start": "09:00", "end": "17:00", "days": ["mon", "tue", "wed", "thu", "fri"], "utc_offset": "-05:00"}}, "triggers": [ {"id": "denise-dm", "when": {"type": "time", "at": "PT10M"}, "actions": [{"type": "nl", "executor": "denise", "instruction": "DM the solver in D_DENISE asking for the Q3 numbers by end of day."}]}, {"id": "denise-answers", "when": {"type": "action", "tool": "slack_send_message", "repeat": true, "where": {"channel_id": {"equals": "D_DENISE"}}}, "actions": [{"type": "nl", "executor": "denise", "delay": {"dist": "exp", "mean": "PT4M", "seed": 7}, "instruction": "Read the solver's latest message in D_DENISE and reply to it."}]}, {"id": "denise-nudges", "when": {"type": "time", "after": "denise-dm", "offset": "PT30M", "unless": {"tool": "slack_send_message", "where": {"channel_id": {"equals": "D_DENISE"}}}}, "actions": [{"type": "nl", "executor": "denise", "delay": "PT2M", "instruction": "Nudge the solver in D_DENISE: you still need the Q3 numbers."}]}, {"id": "bob-escalates", "when": {"type": "time", "after": "denise-nudges", "offset": "PT1H", "unless": {"tool": "slack_send_message", "where": {"channel_id": {"equals": "D_DENISE"}}}}, "actions": [{"type": "nl", "executor": "bob", "instruction": "Post in #finance that the Q3 numbers are overdue and ask the solver for an ETA."}]} ] }deniseandbobare deployed agents (agent_name). When you register through the gateway directly, the same hours go onexecutors.bob.hours. The walk-through below assumes the clock is armed on a Wednesday at 16:00 −05:00. It was checked against the engine.denise-nudges, and the cancel cascades tobob-escalates(anchor: denise-nudges)PT2M, and goes out at 16:43; Bob's escalation is anchored for 17:43deferreduntil Thursday 09:00A reply that lands after a window has closed comes too late. A reply during the hour after the nudge cancels only Bob's escalation.
The firing log carries the new events, and the trajectory names who made each call and which fires it answers:
{"kind": "delayed", "trigger_id": "denise-answers", "action_index": 0, "virtual_seconds": 93.916, "executor": "denise"} {"kind": "deferred", "trigger_id": "bob-escalates", "action_index": 0, "until": "2026-10-15T14:00:00Z", "executor": "bob"} {"kind": "cancelled", "trigger_id": "denise-nudges", "provoking": {"tool": "slack_send_message", "role": "default"}} {"kind": "cancelled", "trigger_id": "bob-escalates", "anchor": "denise-nudges"} {"event_type": "tool_call", "role": "denise@example.com", "tool_call": {"function_name": "slack_send_message"}, "caused_by": [{"trigger_id": "denise-answers", "action_index": 0, "fire": 1}]}Tests
Unit
_next_openingcases.unless: null.executor_hoursand feature checks.Integration (local gateway)
Chaos (Linux VM, local gateways built from this branch)
Stub A2A executors on the host, the in-repo items server behind a gateway built from this branch, faults injected per scenario.
PT30Sdelay at 60×PT10Mat 1×, re-armed to 600× after 2 s;PT20Swith the clock cleared after 2 sPT2Mdelayaction_failedunder bob, bob's trigger re-armed, alice's firedcaused_byboth fires; a later call by that role carries none['action_delay'],['unless'],['executor_hours'], registers nothing; sent raw, that gateway runs aPT10Mdelay at once and fires a deadline after its replyfiring,waiting), then acted 2.7 s after the clock ran again; no failures/triggers/removeduring an 8 s delayanchor: deadline), nothing anchored or firedwhen.roles['named_executors']and['provoking_roles']; sent raw, it misroutes and misreads them, as before🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; no actionable issue remains from this review.Summary
Adds virtual-time delays, executor working hours, cancellable deadlines, and tool-call attribution.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Provoking call or due mark] --> B[Wait for action delay] B --> C{Executor hours apply?} C -->|Yes| D[Wait for opening] C -->|No| E[Run action] D --> E F[Forward clock jump] --> B G[Clock re-arm or clear] --> D E --> H[Record caller and active fires] I[Matching reply before deadline] --> J[Cancel deadline and dependent time triggers]Reviews (3) · Last reviewed commit: "fix(gateway): count a forward re-arm tow..." · Reviewed by Greptile