Follow-up from PR #311 (#310).
Problem
A channel-initiated conversation (Slack/Telegram → agent) runs under the inbound tasks/send request context, and the channel router holds that request synchronously with a 360s (6-minute) HTTP client timeout (forge-cli/channels/router.go NewRouter). The agent loop — and the parked DEFER hook — run under r.Context() (forge-cli/server/a2a_server.go:299).
Consequence: for a channel-initiated task, a human approval must land within ~6 minutes. If it doesn't:
- the router's HTTP call times out → the request context is cancelled → the defer hook sees the cancellation and abandons the deferral (rolls status back, auto-resolves as timeout —
forge-cli/runtime/defer.go:120–128); the tool call fails;
- a later Approve click hits a task with no pending deferral →
/tasks/{id}/decisions returns 404 → the approver sees ⚠️ could not record approve … 404.
This binds even when defer.timeout is longer (default 10 min; examples use 15 m) — the 6-minute channel wait is the real ceiling for channel-initiated tasks, and there is no async re-delivery of the resumed reply to the origin channel. So a legitimately-approved-at-8-minutes action is lost from the user's perspective.
PR #311 adds a startup warning when defer.timeout exceeds the channel router timeout (the cheap guard). This issue is the real fix.
Proposal
Let a deferred, channel-initiated task outlive the synchronous HTTP request and deliver its result asynchronously:
- Detach the deferred task's context from the inbound request when a tool defers (e.g.
context.WithoutCancel + the defer timeout as the deadline), so a client/router HTTP timeout no longer tears down a pending approval. The agent loop keeps running (parked) until the approver acts or the defer timeout fires.
- Return the inbound
tasks/send promptly with the task in deferred state (async A2A task semantics) instead of holding the HTTP connection open for the whole approval window.
- Deliver the resumed result asynchronously to the origin channel once the tool completes — reuse the channel-notifier path (like
SetScheduleNotifier) keyed on the task's original channel + reply target, so the answer lands back in the conversation where it started, in the same session, however long the approval took.
Acceptance
Notes
- Ties into the in-process-only limitation (a process restart still abandons pending deferrals; cross-restart persistence is a separate concern).
- Keep the
session intact across the detach — the resumed goroutine already holds it; this is about the context lifetime + result delivery, not the session itself.
Follow-up from PR #311 (#310).
Problem
A channel-initiated conversation (Slack/Telegram → agent) runs under the inbound
tasks/sendrequest context, and the channel router holds that request synchronously with a 360s (6-minute) HTTP client timeout (forge-cli/channels/router.goNewRouter). The agent loop — and the parked DEFER hook — run underr.Context()(forge-cli/server/a2a_server.go:299).Consequence: for a channel-initiated task, a human approval must land within ~6 minutes. If it doesn't:
forge-cli/runtime/defer.go:120–128); the tool call fails;/tasks/{id}/decisionsreturns 404 → the approver sees⚠️ could not record approve … 404.This binds even when
defer.timeoutis longer (default 10 min; examples use 15 m) — the 6-minute channel wait is the real ceiling for channel-initiated tasks, and there is no async re-delivery of the resumed reply to the origin channel. So a legitimately-approved-at-8-minutes action is lost from the user's perspective.PR #311 adds a startup warning when
defer.timeoutexceeds the channel router timeout (the cheap guard). This issue is the real fix.Proposal
Let a deferred, channel-initiated task outlive the synchronous HTTP request and deliver its result asynchronously:
context.WithoutCancel+ the defertimeoutas the deadline), so a client/router HTTP timeout no longer tears down a pending approval. The agent loop keeps running (parked) until the approver acts or the defer timeout fires.tasks/sendpromptly with the task indeferredstate (async A2A task semantics) instead of holding the HTTP connection open for the whole approval window.SetScheduleNotifier) keyed on the task's original channel + reply target, so the answer lands back in the conversation where it started, in the same session, however long the approval took.Acceptance
tasks/getsee thedeferred→ resolved transition.Notes
sessionintact across the detach — the resumed goroutine already holds it; this is about the context lifetime + result delivery, not the session itself.