Headless agent
The Fabric Desktop by KratosBI agent runs your metadata project on a server, unattended. It runs on Windows or Linux, signs in as a service principal or managed identity, and writes to OneLake exactly as the desktop app does. Develop with Fabric Desktop by KratosBI. Execute on your infrastructure. Store and consume in Microsoft Fabric.
The agent is the first milestone (M1) of the enterprise local execution roadmap. It runs one machine's worth of work. Several agents sharing one queue comes in M3.
Status: built and tested against local Delta tables on Linux and Windows CI. It has not yet run against a live Fabric tenant. Before you trust it with Prod, do the M0 checks in Verify on your tenant.
What it does#
schedules in agent.toml ─┐
fabric-desktop agent enqueue ─┼─> durable queue (SQLite) ─> worker ─> metadata engine ─> OneLake
│ retries, crash recovery │ (the same code the app runs)
│ └─> a write lease per table (LH_Meta)
└─ JSON logs, run_log rows with worker, host, attempt and job id
- Same engine as the app. A job runs an environment's entities through
fabric_desktop.meta.Engine, in dependency order, with the same checks, quarantine, SCD, surrogate keys and run log. Runs show in the app's Monitor as Agent <name> on <host>, with the job id and try in their details. - No one at the keyboard. The agent signs in as an app identity. Nothing ever opens a sign-in window.
- Durable queue. Jobs are kept in
queue.sqlite3in the agent's home folder. A job isqueued, thenrunning, thensucceeded,failedordead. A running job holds a lease that its worker renews; if the worker dies (crash, reboot, killed service), the lease runs out and the job is put back in the queue on the next start. - Retries that make sense. Network errors, throttling (HTTP 429 and 5xx), a lost Delta commit race and a table busy with another writer are retried, waiting
backoff_seconds, then twice that, up tomax_backoff_seconds, formax_attemptstries in all. A retry runs only the tables that didn't finish. A job that still fails isdeadand posts to Teams. A table that fails its checks isfailedstraight away, since running it again would fail the same way. - Safe concurrent writes. The engine writes a table by reading it, merging new rows and committing the result. Before it starts an entity, the agent takes a write lease on that table: a small file in the environment's
LH_Metalakehouse underFiles/_fabric_desktop/leases/, created withIf-None-Match: *, renewed withIf-Match, and deleted when done. A second writer waits for it (up totable_lease_seconds), then retries later. An abandoned lease expires aftertable_lease_seconds. For a local environment the lease files are in its.localfolder, and taking one over is a single step across every process on the PC, so two writers never both win an expired lease. A worker that can't renew its lease for a wholetable_lease_seconds(OneLake unreachable, say) treats it as lost and reports the run that way. Every write, from the agent, the desktop app or a run in Fabric, also commits against the Delta version it read: if anyone committed to the table since, Delta refuses the commit and the engine re-reads the table and merges again (up to 3 times), so no writer's rows are lost even without a lease. - Schedules.
[[schedule]]entries fire in the machine's local time: every N minutes, daily at set times, or weekly. A schedule that comes due while its last run is still queued or running doesn't add a second job. A schedule missed while the agent was down runs once when it starts. - Fabric pipelines can ask for runs. With
[pipeline]turned on, a pipeline writes a run request to the environment'sLH_Metaand waits for the agent's answer next to it. See Run from a Fabric pipeline. - Guard rails. The agent runs only the environments listed in
agent.environments, writes only to the workspaces inagent.workspaceswhen you set it, and never creates lakehouses: an environment is set up by promoting to it from the app. - Logs. One JSON object per line on stdout (journald,
docker logs) and in<home>/logs/agent.log(20 MB, 10 files kept). Every line has the worker and host; job lines addjob_id,env,attemptandsource. Everyrun_logrow the agent writes hasworker,host,attemptandjob_id.
Before you start#
You need:
- A metadata project in a folder the agent can read, usually a Git checkout of the project you develop in the app. The agent reads
layers.toml,environments.tomland the entity files from it, so check out the version you promoted. - Environments set up in Fabric. Promote to each environment from the app first. The agent checks that the environment's deployed metadata version matches the project, as the app does.
- An identity for the agent. One of:
- Service principal with a certificate (any server). Recommended for on-premises.
- Managed identity (Azure VM, or an Arc-enabled server). Recommended on Azure: there is no secret on the machine.
user: the desktop sign-in cached for the account the agent runs as. For trying it out only; the refresh token expires.
Set up the identity#
Create an app registration for the agent (keep it separate from the desktop app's registration): Entra admin center > App registrations > New registration, no redirect URI.
Give it a certificate. For example, on Linux:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=fabric-desktop-agent" \ -keyout key.pem -out cert.pem cat key.pem cert.pem > /etc/fabric-desktop/agent.pem && chmod 600 /etc/fabric-desktop/agent.pemUpload
cert.pemunder Certificates & secrets > Certificates. A.pfxworks too (certificate_pathending.pfx, withcertificate_password_envnaming the variable that holds its password). A client secret works (client_secret_env), but a certificate is safer.Let service principals use Fabric. In the Fabric admin portal, Tenant settings > Developer settings > Service principals can use Fabric APIs: enable it for a security group, and add the agent's app (or managed identity) to that group.
Add the identity to each workspace it runs, as Contributor or higher: the environment's workspace (its layer lakehouses and
LH_Meta) and, if the project uses surrogate keys, the Keys workspace (LH_Keys). It needs no Entra API permissions: an app identity gets what its workspace roles allow.
The full list, in alphabetical order, is in permissions.md.
Configure#
Copy deploy/agent.example.toml to:
| OS | Config | Home (queue, logs, caches) |
|---|---|---|
| Linux | /etc/fabric-desktop/agent.toml |
/var/lib/fabric-desktop |
| Windows | C:\ProgramData\FabricDesktop\agent.toml |
C:\ProgramData\FabricDesktop\agent |
Set FABRIC_DESKTOP_AGENT_CONFIG or pass --config to use another file; set FABRIC_DESKTOP_HOME or agent.home for another home.
Secrets never go in agent.toml, and the agent refuses to start if one does. A certificate is a path; a client secret, certificate password or Teams webhook address is the name of an environment variable (client_secret_env, certificate_password_env, teams_webhook_env).
| Setting | Default | What it does |
|---|---|---|
agent.name |
host name | The worker's name in logs and run rows |
agent.owner |
none | Who looks after this agent (a team or contact), shown in the app as written |
agent.project |
required | The metadata project folder |
agent.environments |
required | The environments this agent may run |
agent.workspaces |
any | Workspace ids this agent may write to |
agent.concurrency |
1 | Jobs at once |
agent.poll_seconds |
5 | How often the loop looks for work |
agent.job_lease_seconds |
300 | How long a silent worker keeps a job before it runs again |
agent.table_lease_seconds |
120 | Write lease length, and how long to wait for a busy table |
agent.status_seconds |
60 | How often to publish the status record to each environment |
auth.type |
required | service_principal, managed_identity or user |
retry.max_attempts |
3 | Tries for retryable errors |
retry.backoff_seconds |
60 | First wait; doubles each try |
retry.max_backoff_seconds |
1800 | Longest wait |
alerts.teams_webhook_env |
none | Variable holding a Teams Workflows webhook address |
pipeline.enabled |
false | Take run requests from Fabric pipelines (below) |
pipeline.environments |
agent.environments |
Whose LH_Meta mailbox to read |
pipeline.entities |
any | Entity names or patterns (silver.*) a pipeline may run |
pipeline.allow_backfill |
false | Let pipelines ask for full rebuilds and cascades |
pipeline.poll_seconds |
30 | How often to look for new requests |
pipeline.retention_days |
14 | When finished requests are removed |
pipeline.claim |
"always" |
"when_free" (Preview): take a new request only while a worker is free, so a busy agent leaves it for one that isn't. See Shared schedules |
pipeline.take_over |
false | (Preview) Take over requests held by an agent that stopped reporting. See Taking over from a lost agent |
schedule.shared |
false | (Preview) Share this schedule with other agents through the mailbox, so each due time runs once. See Shared schedules |
incidents.enabled |
false | Keep each environment's incidents (Preview) |
incidents.seconds |
300 | How often to evaluate (at least 30) |
incidents.digest_over |
5 | More changes than this in one evaluation go to Teams as one digest |
incidents.quiet_hours |
none | "22:00-06:00" on this machine's clock: messages wait until the hours end |
incidents.quiet_except |
["critical"] |
Criticalities that are sent even in quiet hours |
incidents.escalate_after_minutes |
0 (never) | Post incidents still open and unacknowledged after this long to escalate_to |
incidents.escalate_to |
none | The name of the route escalations go to; required with escalate_after_minutes |
[[incidents.route]] |
none | A Teams channel for some incidents: name, webhook_env, and filters (see below) |
Then check it. This validates the file and the project and gets a token for Fabric and for OneLake:
fabric-desktop agent check
Install#
Linux (systemd)#
sudo useradd --system --home /var/lib/fabric-desktop fabric-desktop
sudo python3.12 -m venv /opt/fabric-desktop/venv
sudo /opt/fabric-desktop/venv/bin/pip install ./sidecar # from a checkout of this repository
sudo mkdir -p /etc/fabric-desktop && sudo cp deploy/agent.example.toml /etc/fabric-desktop/agent.toml
sudo chown root:fabric-desktop /etc/fabric-desktop/agent.toml /etc/fabric-desktop/agent.pem
sudo chmod 640 /etc/fabric-desktop/agent.toml /etc/fabric-desktop/agent.pem
sudo cp deploy/linux/fabric-desktop-agent.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now fabric-desktop-agent
journalctl -u fabric-desktop-agent -f
Variables named by *_env settings go in /etc/fabric-desktop/agent.env (NAME=value lines, mode 600). The unit file is deploy/linux/fabric-desktop-agent.service.
Linux (container)#
docker build -f deploy/linux/Dockerfile -t fabric-desktop-agent .
docker run -d --name fd-agent --restart unless-stopped --stop-timeout 900 \
-v /etc/fabric-desktop:/etc/fabric-desktop:ro \
-v /srv/fabric/sales-metadata:/srv/fabric/sales-metadata:ro \
-v fd-agent-home:/var/lib/fabric-desktop \
fabric-desktop-agent
docker exec fd-agent fabric-desktop agent status
Windows#
From an elevated PowerShell in a checkout of this repository, with Python 3.12 installed:
.\deploy\windows\install-agent.ps1 -Account 'CONTOSO\fd-agent$'
notepad C:\ProgramData\FabricDesktop\agent.toml
& "C:\Program Files\FabricDesktopAgent\venv\Scripts\fabric-desktop.exe" agent check
Start-ScheduledTask -TaskPath '\FabricDesktop\' -TaskName Agent
The script installs the agent into a virtual environment and registers the Task Scheduler task \FabricDesktop\Agent. The task starts at boot with no one signed in and restarts every minute if it stops. A group managed service account (gMSA) is best, since there is no password to manage. Set *_env variables as machine environment variables.
Operate#
fabric-desktop agent status # queue counts, next schedule times, the last 20 jobs
fabric-desktop agent enqueue prod # run every Prod entity now
fabric-desktop agent enqueue prod silver.orders gold.fact_sales
fabric-desktop agent enqueue prod silver.orders --cascade --backfill full # rebuild it and everything downstream
enqueue and status work while the service runs; they share the queue file. Stopping the service (SIGTERM, systemctl stop, docker stop, ending the task) stops taking new jobs and waits for running ones. A job cut off anyway runs again at the next start.
| Job state | Meaning | What to do |
|---|---|---|
queued |
Waiting, or waiting to retry (error says why) |
Nothing |
running |
A worker holds it | Nothing |
succeeded |
Every table ran | Nothing |
failed |
A table failed its checks, its config, or permissions | Read error, fix, enqueue again |
dead |
Retryable errors on every try | Read error; usually network, throttling or a table held by another writer |
cancelled |
A pipeline cancelled it, or its deadline passed, before it started | Nothing |
Status records#
Every agent.status_seconds, the agent writes agents/<name>.json to each environment it runs, in LH_Meta/Files/_fabric_desktop/ (a local environment keeps it in its .local/<env>/_control folder). Anyone who can read the environment's LH_Meta can see which agents serve it, whether they are alive and what they are doing, without access to the agent's machine. A record describes only that environment's jobs. Fabric Desktop by KratosBI's workload views and Monitor > Operations > Agents (user guide) read these files.
| Field | Meaning |
|---|---|
contract, kind |
1 and agent_status |
agent, host, pid, version |
Which agent, where, and which Fabric Desktop by KratosBI version |
owner, identity |
agent.owner as written (null when not set), and auth.type: service_principal, managed_identity or user. No ids, certificate paths or secrets |
credential |
Facts about the agent's credential, never the credential itself: kind (certificate, secret, managed_identity or user), expires_at (a certificate's end date, read from auth.certificate_path; null for a secret or managed identity, whose expiry the agent can't see), last_token_at, and last_token_error (at and the error, with secrets, ids, emails and user paths removed). The path, thumbprint, key and password are never published |
state |
running, stopping (waiting for running jobs) or stopped (shut down cleanly) |
env, environments |
The environment this record is for, and every environment the agent runs |
started_at, heartbeat_at, stale_after |
UTC times. Treat an agent as offline once stale_after (three beats after heartbeat_at) has passed without a newer record |
status_seconds, concurrency |
Its settings |
pipeline |
Whether it takes pipeline requests here, its entity allowlist, and whether it allows backfills |
incidents |
Whether it checks incidents and how often, its quiet hours, escalation, and each route's name and filters. Webhook addresses and the variables holding them are never included |
counts |
This environment's jobs in each state: queued, running, succeeded, failed, dead, cancelled |
running |
Each running job: job_id, source, attempts, entities, started_at |
schedules |
This environment's schedules: id, when each fires next, the entities it runs (an empty list means every entity) and when as written in agent.toml ({"kind": "cron", "every_minutes"}, {"kind": "daily", "times"} or {"kind": "weekly", "times", "weekdays"}) |
recent |
The last 10 finished jobs: job_id, state, source, finished_at, error (first 300 characters) |
Fields may be added within contract 1; readers should ignore fields they don't know. Agents built before these fields were added leave out owner, identity, credential and each schedule's entities and when; read a missing field as unknown, never as "all" or "none". A record that can't be written (OneLake unreachable, LH_Meta missing) is logged and tried again at the next beat; it never stops the agent.
Incidents (Preview)#
With [incidents] enabled = true, every incidents.seconds the agent reads each environment's run log (the last 2,000 runs) and its entity files, and keeps one incident per problem in incidents/incidents.json in the same control area as the status records. The desktop app evaluates the same way when someone asks it to and writes to the same file, so the app and any number of agents never open two incidents for one problem: every change is a conditional write that only lands if nobody wrote in between, and is redone otherwise.
| Kind | Opens when | Recovers when |
|---|---|---|
| Run failed | A table's newest finished run failed | Its newest finished run succeeded |
| Needs a decision | A table's newest finished run stopped for schema drift or quarantined rows | Its newest finished run went through |
| Late | Its freshness promise is missed in this environment | A successful run is recent enough |
| Objective missed | Fewer of its recent runs succeeded than its objective asks | The rate is back up |
| Agent offline | Another agent's status record (agents/<name>.json) went past its stale_after without saying it stopped |
It reports again, or says it stopped |
| Can't evaluate | The run log couldn't be read (offline, no access, LH_Meta missing) |
It can be read again |
"Can't evaluate" is about reading, never about a workload: while it is open, workload incidents in that environment stay as they were instead of being marked recovered. The same holds for agent records: if agents/ can't be listed, agent-offline incidents stay as they are. An agent never reports itself offline; another agent or the app has to notice it. An agent stopped cleanly (state = "stopped") isn't offline.
A failed run's incident carries a likely category matched from the error text: Access, Throttled or capacity, Network, Table busy, Source missing, Code or schema, Data quality, Code, or Unrecognized, with whether a retry alone usually helps. It is a hint for where to look, never a diagnosis; the run's own error is the evidence. A new failure of a table with an open incident counts as another occurrence of it. Someone who resolves an incident by hand doesn't see it again for the same failed run, only for a new one.
With alerts.teams_webhook_env set, each change goes to Teams: one message when an incident opens or opens again, one when it recovers, and one digest when an evaluation changes more than incidents.digest_over. A message Teams doesn't take waits in alerts.sqlite3 in the agent's home and goes with the next evaluation, marked Delayed with when it happened; after three days it is dropped. The agent's own "a job didn't finish" alert is unchanged.
Routes send some incidents to their own channel. Each [[incidents.route]] has a name, webhook_env (the name of the environment variable holding the webhook; the address itself never goes in the file), and any of teams, owners, criticality, kinds and envs, each a list; an empty filter matches everything, and matching ignores case. A change goes to every route that matches it, and to alerts.teams_webhook_env when none does. A route whose variable isn't set is logged and skipped.
[incidents]
enabled = true
quiet_hours = "22:00-06:00"
escalate_after_minutes = 60
escalate_to = "on-call"
[[incidents.route]]
name = "sales"
webhook_env = "SALES_TEAMS_WEBHOOK"
teams = ["sales"]
[[incidents.route]]
name = "on-call"
webhook_env = "ONCALL_TEAMS_WEBHOOK"
criticality = ["critical"]
During quiet hours, messages for incidents whose criticality isn't in quiet_except wait in the outbox and go out when the hours end, marked Held during quiet hours since when they happened. Escalations are never held. An incident still open (nobody acknowledged it) escalate_after_minutes after it opened is marked escalated once, with a note, however many agents evaluate; the escalation route gets one Escalated message. Escalation doesn't page anyone by itself: it is a Teams post to the channel you name.
The file keeps every open incident and the last 200 resolved ones. Each has id, key (kind, workload, environment), kind, workload, name, env, title, text, evidence (run id and start time, or the freshness or objective numbers), state (open, acknowledged or resolved), opened_at, last_seen_at, occurrences, opened_by, owner (the entity's declared owner), team, criticality, classification (failed runs), escalated_at and escalated_to, who acknowledged and resolved it and when, resolution (recovered, cleared or resolved by hand) and notes. Contract 1; fields may be added.
Run from a Fabric pipeline#
Preview. Built and tested against local folders and a mocked OneLake. It has not yet run on a live tenant; see step 6 of Verify on your tenant.
A Fabric pipeline can ask the agent to run an environment and wait for the result, so the agent's runs fit into pipelines you already orchestrate in Fabric (after a Copy job lands files, before a semantic model refresh). The pipeline and the agent talk through files in the environment's LH_Meta lakehouse. Nothing listens on a network port, and the agent needs no inbound firewall rule.
pipeline ── writes ──> LH_Meta/Files/_fabric_desktop/requests/<id>.json
agent ── claims, queues and runs it, then writes ──> status/<id>.json
pipeline ── reads status/<id>.json until it is final; writes cancel/<id>.json to cancel
Turn it on in agent.toml:
[pipeline]
enabled = true
entities = ["silver.*", "gold.*"] # optional: what pipelines may run
Add the sample notebook. Import examples/pipeline/run_on_agent.ipynb into the environment's workspace as a Python notebook and set LH_Meta as its default lakehouse. In your pipeline, add a Notebook activity for it with these base parameters:
| Parameter | Example | Meaning |
|---|---|---|
env |
prod |
The environment. It must be the one whose LH_Meta the notebook writes to |
entities |
silver.orders,gold.fact_sales |
Comma-separated; empty runs every entity |
request_id |
@pipeline().RunId |
Makes a retried activity wait for the same run instead of asking twice |
submitted_by |
@concat(pipeline().Pipeline, ' ', pipeline().RunId) |
Recorded in the status, as given |
start_within_minutes |
60 |
The request expires if no agent has started it by then |
timeout_minutes |
240 |
Stop waiting, cancel the request if it hasn't started, and fail |
The activity succeeds when the run succeeds and fails otherwise, with the reason in its error. Its output (notebookutils.notebook.exit) is the final status, so later activities can read the run ids and row counts.
The contract (version 1)#
A request is a JSON object in requests/<id>.json. The id is 1 to 64 letters, digits, ., _ or -. Write the file under another name first and rename it, as the sample does; the agent gives a file it can't read 60 seconds before rejecting it.
| Field | Required | Meaning |
|---|---|---|
contract |
no (1) | The contract version. An agent refuses a version it doesn't speak |
env |
yes | The environment to run. It must match the mailbox it is written to |
entities |
no | Entity names; empty or missing runs every entity in dependency order |
backfill |
no | "" or "full". Needs pipeline.allow_backfill |
cascade |
no | true rebuilds one entity and everything downstream of it. Needs pipeline.allow_backfill |
deadline |
no | ISO 8601 time with a zone (2026-10-17T03:00:00Z): the latest time the run may start |
submitted_by |
no | Free text up to 200 characters, recorded in the status |
The agent answers in status/<id>.json:
| Field | Meaning |
|---|---|
state |
accepted, running, then one final state: succeeded, failed, cancelled, expired or rejected |
reason |
Why it was rejected, cancelled or expired |
error |
Why the run failed (last_error while a retry waits) |
agent, host |
Which agent took it |
job_id, attempts |
The agent's job; job_id is also on every run_log row the run writes |
received_at, started_at, finished_at, updated_at |
UTC times |
result |
tables, ok, failed, rows_written and run_ids |
cancel |
Set when a cancel arrived while the run was going |
contract, request_id, env, entities, backfill, cascade, deadline, submitted_by |
As requested |
A final status never changes again. Fields may be added within version 1; readers should ignore fields they don't know.
How it stays safe:
- Each request runs once. The agent claims a request by creating its status file with
If-None-Match: *, so when several agents read one mailbox only one takes it. The request id is the job's dedupe key in the agent's queue, so an agent that crashes after claiming queues it when it starts again, and never twice. - Only the environment's own people can ask. A request runs the environment whose
LH_Metait is written to, and only identities that can write that lakehouse can write it. A Test pipeline can't run Prod. The agent also checkspipeline.environments,pipeline.entitiesandpipeline.allow_backfill, and that each entity exists in the project. - Cancelling. Write any file to
cancel/<id>.json. A request that hasn't started iscancelled. A run already going finishes its tables (stopping mid-write could leave a table half done) and isn't retried if it fails. - Deadlines. A request not started by its
deadline, including one waiting for a retry, isexpiredand its job is cancelled. - Housekeeping. Finished requests and their status and cancel files are removed after
pipeline.retention_days.
Shared schedules (Preview)#
Give the same schedule to several agents and set shared = true on it. Each due time then runs once, on whichever agent claims it, rather than once per agent. If one agent is down, the others still run the schedule. The design, and what is still to come, is in Multiple workers.
[pipeline]
enabled = true
environments = ["prod"]
claim = "when_free" # optional: a busy agent leaves new requests for one with a free worker
[[schedule]]
id = "prod-hourly" # the same id, env, entities and times on every agent that shares it
env = "prod"
entities = ["silver.orders"]
every_minutes = 60
shared = true
- How it works. When a shared schedule is due, each agent tries to create
requests/sched-<id>-<slot>.jsonin the environment's mailbox withIf-None-Match: *. Only the first create lands. The request is then claimed like any pipeline request, so one agent runs it. The<slot>comes from the due time in UTC, for examplesched-prod-hourly-20270115T0900Z. - Same times on every agent. An every-N-minutes shared schedule runs on multiples of its interval, counted in UTC (every hour on the hour, for example), so every agent computes the same due times. Daily and weekly times are each machine's local time, so agents sharing them must use the same time zone.
- No late runs. A slot's request has a deadline: its next slot, at most 6 hours on. An agent that comes up after that doesn't create it. An unclaimed request past its deadline is
expired. Withclaim = "when_free", if every agent stays busy until the deadline, that due time expires without running. That is by design, not a lost run: the next due time runs as normal. - Requirements. A shared schedule needs
[pipeline] enabled = truefor its environment, its id is at most 40 characters, and an every-N-minutes shared schedule uses whole minutes, at least 1. Withpipeline.entities, it must name entities the allowlist takes. A shared backfill needspipeline.allow_backfill. - Not yet. A request claimed by an agent that then stops reporting waits for that agent, unless the others have
take_over = true(below). Monitor > Operations > Agents doesn't warn about a schedule that agents share this way. A late or expired shared due time doesn't open an incident yet.
Taking over from a lost agent (Preview)#
With [pipeline] take_over = true, an agent takes over a pipeline request (a shared schedule's, or any other) that another agent accepted and then stopped reporting on. That agent counts as lost once its status record in agents/<name>.json is past its stale_after, plus a minute for clock differences.
- One agent takes it. The takeover replaces the request's status file only if it hasn't changed since it was read (
If-Match), so when several agents try at once, one wins. The status then names the new agent,taken_over_from,taken_over_at, andagents, every agent that has held it. - The whole request runs again. Tables the lost agent finished are run again. A request whose
deadlinehas passed endsexpiredinstead of starting again. Before it writes each table, the new agent takes that table's write lease, so it waits for a lease the lost agent still holds to run out. - The lost agent stands down. An agent that comes back after its request was taken over (it was paused, or its network was down) cancels its own copy. A run it is still in stops before its next table, without writing or recording it, and its audit log says
handed_over. A table write already under way when it comes back can't be stopped; the table lease is what keeps the two writers apart then. - Not forever. A request that
retry.max_attemptsagents have held endsfailed, with each of them named, so a request that crashes every agent doesn't go round them all for ever. - What isn't taken over. An agent without a status record, or with one that can't be read, keeps its requests, because nothing shows it is lost. An agent doesn't take over a request it would refuse as new (its
pipeline.entities,allow_backfillor environments). Withclaim = "when_free", it takes one over only while a worker is free.
Verify on your tenant#
Nothing here has run against a live Fabric tenant yet. Before Prod, check these on a Dev workspace (roadmap M0):
fabric-desktop agent checksucceeds with your service principal (tokens for Fabric and OneLake).fabric-desktop agent enqueue devwrites the Dev tables, and they show in the lakehouse after a refresh.A lease file appears in
LH_Meta/Files/_fabric_desktop/leases/while a table runs and is gone after. OneLake's support forIf-None-MatchandIf-Matchon these requests is the key thing to confirm.Two agents, or the agent and the app, running the same table at once: one waits for the other and no rows are lost.
Status records:
LH_Meta/Files/_fabric_desktop/agents/<name>.jsonappears withinstatus_secondsof starting the agent, itsheartbeat_atmoves on, andstateisstoppedaftersystemctl stop(or stopping the Windows service).Pipeline requests: with
[pipeline] enabled = true, run a pipeline whose Notebook activity runsrun_on_agent.ipynbwithentitiesset to one bronze entity. The activity succeeds,status/<RunId>.jsoninLH_Meta/Files/_fabric_desktop/endssucceededwith the agent'sjob_id, and the Monitor shows the run. Then run it with a pipeline identity that can't writeLH_Metaand check the activity fails to write the request. Then writecancel/<id>.jsonbefore the agent's next poll and check the status endscancelled.Incidents: with
[incidents] enabled = trueand a table that fails in Dev,LH_Meta/Files/_fabric_desktop/incidents/incidents.jsonappears withinincidents.secondswith one open incident, Teams gets one message, and a second agent (or the app) evaluating at the same time doesn't add another. After a good run the incident is resolved as recovered. Turn the network off for one evaluation and check Teams gets the messages marked Delayed afterwards. Stop a second agent with Task Manager (not cleanly) and check the first opens "Agent … stopped reporting" after itsstale_after, and recovers it when the second is back. With a route andquiet_hourscovering now, check a non-critical failure waits and a critical one doesn't.Shared schedules (Preview): give two agents the same
shared = trueschedule every 15 minutes. Over an hour,requests/gets onesched-…file per quarter hour, and each runs once, on one agent. Stop one agent and check the other runs every slot.Takeover (Preview): with
take_over = trueon two agents, end one agent's process with Task Manager orkill -9while it runs a pipeline request. After itsstale_afterand one poll, the other agent's name is instatus/<id>.json,agentslists both, and the run endssucceeded. Start the first agent again and check its log says the request was taken over and it writes nothing for it. This is the first timeIf-Matchreplace on OneLake is used for takeover.
Limits in this version#
- Pipeline requests don't record who wrote the request file, only the
submitted_bytext the pipeline gives. Whether OneLake reports a file's writer to the agent is still to verify. - One agent per queue. Two agents on different machines each keep their own queue. Table leases still stop them writing the same table at once, but nothing stops both from running the same schedule, unless it is a shared schedule (Preview, untested on a tenant). Otherwise, give each environment's schedules to one agent.
- The desktop app and runs in Fabric don't take write leases. Their writes are still safe (each commits against the version it read and merges again on a conflict), but a busy table means retries instead of waiting in turn.
- Notebooks and job files don't run in the agent yet; it runs metadata environments.
- Agent environments run on the machine, whatever their Runs on setting in the app.
- Lease expiry uses the holder's clock. Keep agent clocks in sync (NTP).
- Incidents (Preview) cover metadata tables and agents only. The app shows them in Monitor > Operations > Incidents (user guide). The app leaves an environment to the agent while a running agent checks its incidents; when no agent does, the app checks it while open and posts to its own webhook. Quiet hours use the agent machine's clock, and routes are set per agent: two agents with different routes post the same incident to different channels.
- The agent doesn't update itself. Install a new version with
pip installand restart the service.
Need a hand?
Find a fix in troubleshooting, or share a reproducible issue with the community.
Get support ↗Improve this guide on GitHub ↗