Skip to main content
Version: 2026.1

Agent Tasks

A headless agent task delegates a unit of work to an agent from PHP and returns a durable, machine-readable result - no browser, no human driving the conversation. The consumer-facing contract (the facade, the result envelope, the Copilot step) lives in Features → Agent Tasks. This page explains how the mechanism is built, and why it is built that way.

The whole design is shaped by one hard constraint: the work runs unattended, in a separate Node process (the agent-server) that PHP does not control, over an HTTP channel that can drop a response or die mid-turn. Despite that, every task must reach exactly one terminal result a caller can trust. Everything below follows from a single rule that makes this possible:

PHP is the system of record. Node is stateless execution. The resultEnvelope on the task row is the one fact a caller reads. Each mechanism on this page exists to defend that rule against a specific way execution can fail.

Terminology note. Core Concepts defines Task as one agent turn run by the in-process task runner - a single response to a single message. An agent task (capitalized AgentTask in code) is a higher-level concept: a durable, multi-turn unit of work with its own database row, status, and final result. A single agent task spans many turns of the former kind (its first turn, any nudge turn, any auto-continue turns). This page writes "task turn" for the lower-level concept and "agent task" / "task" for the higher-level one.

The moving parts

Consumer (Copilot step | collab bridge | console command | any PHP module)
| start() / waitFor() / get() / cancel() / continueTask()
v
AgentTaskService (PHP facade) ---- AgentTask row: status + resultEnvelope <-- watchdog
| (the system of record) (pimcore:maintenance)
| POST /agent-server/internal/tasks/:sessionId/turn (admin token, container network only)
v
agent-server (Node) -- owns the whole turn lifecycle: the pauseAfter auto-continue loop and the
finalize nudge. On turn end it POSTs a thin { turnId, turnStatus } report back to PHP.
|
v (from inside the turn, over MCP)
finalize_task -- validates outputSchema, commits the terminal envelope directly
propose_* / direct-write tools -- the actual work (see Features → Autonomy and HITL)
|
v on any terminal transition
AgentTaskCompletedEvent (inline, synchronous, advisory) -> consumers notify / reassign / offload

Two invariants fall straight out of the governing rule, and both recur throughout the walkthrough:

  • A caller only ever reads the record. waitFor() polls the task's status column. It holds no connection to the agent-server and never depends on the completion event, a messenger worker, or the maintenance cron for the happy path. If the caller walks away, the run is unaffected.
  • Terminal status is written exactly one way. Up to four writers try to finish a task - the agent, the turn report, a caller's cancel(), and the watchdog. They all go through the same compare-and-swap (CAS), so exactly one wins and the rest are no-ops. This is what makes an unattended, racy lifecycle safe, and it reappears at every stage below.

The life of a task

The mechanism reads best as the path one task travels: it is started and handed off, run on Node, then finished in one of two ways - with the watchdog as the backstop for when finishing never happens, and a final event to announce it. A field-by-field reference for the AgentTask row is at the end of this page.

Starting a task

AgentTaskService::start() is the only synchronous step, and it does just enough to hand the work off before returning - in milliseconds, not the duration of the run:

  1. Create the session with origin = 'task' and mint its MCP token. A task runs on an ordinary chat session; the origin flag is what later earns it task-specific access rules and tool overrides.
  2. Persist the AgentTask row as queued. This is the pre-dispatch instant only - there is no queue in this feature. queued exists so a durable row precedes the first dispatch.
  3. Compose the first message from the caller's instructions, the elements in scope, and the fixed task contract.
  4. Dispatch the first turn, then CAS queued -> running and record the dispatched turn's id as lastTurnId - the key every later report is matched against.

Two things are fixed here for the life of the task and never change: who the agent acts as and who may review it (see Identity and delegated access), and the run's budget - its deadline and auto-continue allowance. After the CAS to running, PHP has handed off. It will not touch the task again until something reports back or the watchdog steps in.

Running the turn

Execution happens entirely on the agent-server. PHP reaches it through a single internal endpoint, POST /agent-server/internal/tasks/{sessionId}/turn, whose design reflects that this is a privileged, retryable hand-off:

  • Guarded. It requires the shared admin bearer token and rejects any session whose origin is not 'task', so even a leaked token cannot inject a turn into a regular chat session. The token is a secondary control; the primary one is network containment (Securing the internal endpoint).
  • Idempotent. The agent-server tracks the last accepted messageId per session. A retried dispatch of the same message replays a 200 instead of starting a second turn; a dispatch with a different id while one is in flight is rejected with 409 rather than racing it. A dropped response on start() is therefore safe to retry.

The dispatch carries a small taskContext, and this is where "unattended" becomes concrete:

{ "unattended": true, "deadlineAt": 1735900000000, "remainingAutoContinues": 5 }

Because no human is watching, the ask_user tool is removed and the automatic pauseAfter "continue or stop" checkpoint answers Continue on its own while the budget holds (remainingAutoContinues > 0 and the deadline not passed). When the budget runs out it answers Stop, ending the turn. (Every task turn also gets two fixed tool overrides - finalize_task injected, ask_user excluded - detailed in Tool Security.)

From dispatch onward, Node owns the entire turn lifecycle (task-turn-lifecycle.ts) - the auto-continue loop and the finalize nudge described next. PHP is out of the loop during execution and learns what happened only when Node reports back, or when the watchdog infers it.

Finishing: how a task reaches its result

A turn ends in one of two ways, and each settles through a different path.

The agent finalizes - the happy path. The agent calls the finalize_task MCP tool, which builds the result envelope and CASes the task from running straight to terminal - mid-turn, before the turn even ends. Committing the result the instant it is known is what makes it durable: if the turn's tail then crashes or its HTTP response is lost, the outcome is already recorded. A second finalize_task call finds no non-terminal task and is rejected - the first finalize wins.

The turn ends without finalizing. An agent can answer in prose and simply forget to finalize. Node handles that before involving PHP: it runs one leashed nudge turn on the same session, its auto-continue budget zeroed so it can only end in Stop, carrying a plain instruction to call finalize_task now. If the agent finalizes during the nudge, that is just the happy path a beat later. If it still does not, Node reports the original turn's id back to PHP with a turnStatus, and PHP settles the outcome the agent did not settle itself:

Node reports turnStatusPHP settles the task as
completed - ran, but never called finalize_task, even after the nudgefailed(not_finalized)
failed - an SDK/turn-level errorfailed(agent_error)
cancelledcancelled

A report whose turnId does not match the task's lastTurnId, or that targets an already-terminal task, is stale and does nothing. (If dispatching the nudge itself fails - a gone adapter session, say - Node reports the turn unfinalized directly, so PHP still settles rather than waiting on the watchdog.)

Both paths - plus cancel() and the watchdog - converge on the same compare-and-swap on status: a single UPDATE ... WHERE status IN (...) on the raw DBAL connection that reports whether it matched a row. That is why four possible finishers can never double-settle: whoever wins the CAS writes the envelope, and the losers no-op. The primitive deliberately does not clear() the EntityManager, because it runs in contexts - notably the Copilot step - that hold unrelated entities (a Generic Execution Engine JobRun) in the same unit of work.

Nothing on this happy path touches a messenger transport or the maintenance cron. finalize_task and the turn report both run inline in a php-fpm request, and waitFor() sees the result by polling the column.

The watchdog: guaranteeing termination

Inline settlement covers every case where Node lives long enough to report back. But Node can crash, its report can be dropped, or a turn can run past its deadline - and then no inline settlement ever arrives. AgentTaskWatchdogTask, run from pimcore:maintenance, is the backstop that guarantees every task still reaches terminal. It makes three passes, each task settled in its own try/catch so one bad row never aborts the batch:

PassTriggerAction
TimeoutdeadlineAt has passed on a queued/running taskbest-effort cancel the live turn, then settle failed(timeout)
Orphan detectiona running task has shown no sign of life for 10 minutesderive the outcome from the session's last message (below)
Retentiona terminal task finished more than retention_days ago (default 30)delete the session, then the task row (max 100 per run)

How "no sign of life" is measured. A task is stale when max(session.lastActivity, task.createdAt) is older than the 10-minute cutoff. Every message write - including the agent-server's debounced streaming flushes - bumps lastActivity, so a genuinely long-running turn keeps refreshing it and is never mistaken for orphaned.

How an orphaned outcome is derived. A finalized turn always commits its own status, so a stale running task must have had a turn that ended without one. The watchdog reads the session's last assistant message to decide what happened:

  • complete -> failed(not_finalized) - the turn ended but nothing was recorded (for example a lost report).
  • error -> failed(agent_error).
  • streaming -> failed(orphaned) - interrupted mid-flight, commonly an agent-server restart.
  • none yet -> left alone as ambiguous; deadlineAt stays the backstop.

Announcing completion

Whichever finisher wins the CAS - agent, turn report, cancel(), or watchdog - a single AgentTaskCompletedEvent fires inline and synchronously, in the same request, each dispatch site wrapped in its own try/catch so a throwing listener can never corrupt the already-durable settlement. The event is advisory fan-out only: consumers use it for follow-up (notify a user, reassign a collab thread, enqueue work). Per the governing rule, no caller may depend on a listener having run when it observes the task as terminal - anything a caller needs must already be in the envelope.

Identity and delegated access

A task carries two distinct identities, and keeping them apart is a design goal, not an accident:

  • The acting user (actingUserId) is who the agent operates as - the agent configuration's executionUser if set, otherwise the caller-supplied actingUserId. A task with neither fails closed at start() (InvalidTaskRequestException), mirroring EffectiveUserResolver's no-silent-fallback rule (Authentication). Every tool call and permission check inside the turns runs as this identity, and the MCP token is minted for it - never for the reviewer.
  • The designated user (designatedUserId) is optional and is who reviews the result. It feeds the default access resolver and sidebar discovery (below). When omitted, session ownership - and so who can review the proposals in Pimcore Studio - falls back to the acting user.

Separating them means an agent can run with elevated privileges while a proposal it raises is only ever applied within a human reviewer's own, narrower permissions.

Who may open a task session

The bundle does not model roles or multiple assignees. Instead, access to a task-origin session is answered by a pluggable resolver, keyed by the task's initiator:

interface TaskSessionAccessResolverInterface {
public function canAccess(TaskSessionRef $ref, int $userId): bool; // {sessionId, initiator, initiatorContext}
}

TaskSessionAccessRegistry routes the check to the resolver tagged for that initiator, falling back to DefaultTaskSessionAccessResolver when none is registered:

  • Default resolver: canAccess = userId === designatedUserId || userId === actingUserId.
  • Integration-specific resolvers (a collab bridge tagged with its own initiator, say) answer from their own ownership data - a thread's participant list, a role model - without the bundle knowing anything about it. No mirroring, no sync.
  • A resolver that throws denies access rather than propagating, so a bug in one initiator's resolver cannot become an information-disclosure or outage for every other task session.

SessionAccessVoter ties this to the rest of Studio: a 'chat' session keeps its ordinary owner check; a 'task' session delegates entirely to the registry, reading initiator/initiatorContext from the latest AgentTask row. This is the same check that gates minting a session's MCP token, so a human can never act beyond what the resolver grants - and every human action still runs under that human's own identity. Only the task's automated turns run as the acting user.

Discovery

A user's Pimcore Studio session sidebar also lists task sessions where designatedUserId matches their id, badged origin: 'task' (AgentSessionRepository::findVisibleToUserPaginated()). A task session with no designatedUserId - routed through an integration-specific resolver, such as a collab thread - is not listed there; the owning integration's own UI is where a participant opens it.

Continuation

continueTask(previousTaskId, instructions, ...) starts a new AgentTask on the existing session of a prior, now-terminal task. It asserts the previous task is terminal and its session has no other non-terminal task in flight, re-mints the session's MCP token (the caller holds no live token for a session it did not just create), and reuses the previous task's agentName, actingUserId, and designatedUserId. The earlier session's context - transcript and any proposals already raised - carries forward; only instructions (and, optionally, deadlineMinutes / maxAutoContinues / outputSchema) come from the new call. outputSchema is supplied by this call or omitted; it is never inherited from the previous task, so each continuation keeps its own explicit finalize-output contract. elements and initiatorContext are neither re-specified nor inherited. The caller only ever holds task ids; the sessionId stays internal to the facade.

The session behind a task, and why the result outlives it

AgentSession.origin ('chat' by default, 'task' for a session created by start()) is the only schema change to the sessions table. In every other respect a task session is an ordinary session: deleting it uses the same AgentSessionService::deleteSession() path (which revokes the MCP token) when a user removes it, or deleteSessionAsSystem() when the retention watchdog reaps it.

bundle_agent_tasks.session_id deliberately carries no foreign key to bundle_agent_sessions.id; it is a plain indexed column. get() and waitFor() read the AgentTask row directly and never join to the session, so a finalized result stays retrievable by taskId even after the session is gone - whether the retention watchdog reaped it (session first, then task) or a reviewer deleted it. A caller holding a taskId can always fetch its result, without caring whether the transcript still exists. This is the record-vs-execution split at the storage layer: the durable outcome is decoupled from the disposable conversation that produced it.

Securing the internal endpoint

POST /agent-server/internal/tasks/{sessionId}/turn and .../cancel are guarded only by the shared admin bearer token - the same token that gates config reload, which can itself define arbitrary executionUser agents. A leaked admin token therefore means injecting instructions into sessions that run under agent privileges: effectively full control of the agent subsystem. Token secrecy is a secondary control. The primary control is network containment.

Hard deployment rule. /agent-server/internal/* must never be reverse-proxied to the public edge. The nginx configuration in Installation → Configure the nginx proxy proxies only ^~ /agent-server/api/; there is no location block for /agent-server/internal/, and none should ever be added. PHP reaches these endpoints directly on the container network (http://agent-server:3032/agent-server/internal/...); they are not meant to be reachable from outside it at all, with or without the token.

AgentTask field reference

The durable record is the AgentTask entity (Doctrine, table bundle_agent_tasks). Its status moves queued -> running -> succeeded | failed | cancelled: queued is the pre-dispatch instant, running is the only non-terminal state after dispatch, and the three terminal states are only ever entered through the CAS described in Finishing.

FieldPurpose
id, sessionIdTask handle, and the chat session (n:1) it runs on. continueTask() starts a new task on an existing session; at most one non-terminal task per session at a time.
statusqueued -> running -> succeeded / failed / cancelled.
initiator, initiatorContextConsumer tag (copilot, cli, an integration's own name) plus correlation data (e.g. {jobRunId}); also routes the access resolver.
agentName, instructions, elementsThe task's declared inputs, so get() is self-describing without loading the transcript.
outputSchemaOptional caller-supplied JSON Schema, validated inside finalize_task.
designatedUserIdOptional reviewer id; input to the default access resolver and to sidebar discovery.
actingUserIdThe identity the agent operates as; required, resolved fail-closed at start().
resultEnvelopeThe frozen result, written once at settlement.
maxAutoContinues, deadlineAtThe run budget passed to the agent-server; deadlineAt also fires the watchdog's timeout pass.
validationFailuresIn-turn outputSchema retry counter inside finalize_task (capped at 3).
lastTurnIdThe dispatched turn's message id; the idempotency key a turn report is matched against.
createdAt, finishedAtLifecycle timestamps; createdAt also feeds the watchdog's staleness basis alongside the session's lastActivity.