About delivery
Every event is delivered as soon as it is written. There is no batch at the end of a round and nothing to flush.
The logs setting in vinaya. names exactly one destination, a folder or a server. Vinaya resolves it once per process and then delivers every event there.
Delivery is fail-open: writing an event never throws. A failed write or send warns at most once per process and leaves the events queued. The run carries on at full speed.
How it works
Vinaya picks one of three destinations:
| Destination | What happens |
|---|---|
folder | The event is appended to <folder>/. |
server | The event is appended to a local retry queue, which is then sent to the server. |
none | Nothing is recorded. Only happens in CI. |
It chooses by checking these rules in order. The first that applies wins.
- 1
A confined dispatch's spool
If
VINAYA_is set and not empty, events go to a folder at that spool. This beats every other rule.LOG_ SPOOL_ DIR - 2
The trusted value for unattended runs
For an unattended caller (an agent, or a CI job), a
logsvalue in the working tree counts only if the default branch declares the identical value. With no locallogs, the default branch's value applies. An attended caller (you, at your terminal) uses the working tree's value directly. See Redaction and trust. - 3
CI
On a CI host (
GITHUB_ACTIONSis set), events go to a server if one is configured and its credential is present. Otherwise nothing is recorded. CI never writes to a folder. - 4
A server
A
logs.urlsends events to that server, with header values filled in from environment variables. - 5
A folder
A
logs.folderinside the repository is ignored. Otherwise events go to the configured folder, or to the default folder.
Folder delivery
A folder is the default.
- With no
logsconfigured, the folder is<runtimeDir>/.logs/ - Each file is
<folder>/, where<owner>- <repo>/ <issue- or- none>. ndjson <issue-or-none>is the event's Issue number, ornone. - Writing the line is the delivery. You can read the file while the run is going.
logs.foldermust be an absolute path outside the repository. A folder inside the repository is refused for every caller.- A folder file rotates at 8 MiB, the same way as the retry queue below.
Server delivery
With a server, each event is first appended to a local retry queue, and the queue is then sent to the server.
- Events are sent as
application/in a POST body.x- ndjson - Within one process, sends happen one after another, never in parallel.
- The server stores each event by its
event_id, so a chunk sent twice creates no duplicates.
The local retry queue
| Item | Value |
|---|---|
| Path | ~/, or ~/ when the remote cannot be resolved |
Why not under runtimeDir | The queue is plumbing for this machine, not a file of a task run |
| Each write | Folder created with mode 0o700; a symlink or non-regular file is refused; the file is opened for append with mode 0o600, never following a symlink; one write per line, ending in a newline |
| Rotation | At 8 MiB the file moves to a single slot, <name>.1.ndjson, overwriting the previous slot |
| Overflow | When a rotation overwrites events, a warning names the events lost (once per process) |
How the queue is sent
- 1
Oldest first
The queue is sent one file at a time, oldest first: a leftover
<name>., thendraining. ndjson <name>.1.ndjson, then the live file. - 2
Set aside before reading
Each file is renamed to
<name>.before a byte is read. New events keep landing in a fresh live file that the send never touches.draining. ndjson - 3
Sent in chunks
Chunks of at most 5 MiB are cut from the head of the file. Each POST times out after 30 seconds. Once the server answers with a
2xx, the bytes it confirmed are removed before the next chunk is built. - 4
Stopped on the first failure
A refused or failed chunk ends the send. The draining file keeps exactly what was not confirmed, and the next send retries the same head chunk. A non-2xx answer (for example
401) never marks a line as bad: the queue stays whole.
Before it is sent, each line is checked and redacted again. A line that is invalid, of an unknown version, or larger than one POST moves to <name>. with its reason, classification and identity, and the send carries on. This warns once per process. If the rejected file cannot be written, the send stops.
Only one sender at a time
- A
.flush-lockfile next to the queue is created exclusively and held for the whole send, across processes. Another process that finds it taken returns at once and catches up on its next event. - The holder renews the lock before every chunk. A lock not renewed for two minutes (four times the 30-second POST timeout) is taken over. A holder that finds its lock taken over stops.
Sandboxed agents
A sandboxed Developer or Reviewer cannot write to <runtimeDir>/. So the driver sets up a spool inside the dispatch's scratch area and passes it as VINAYA_.
After the turn, before the scratch area is removed, the driver copies only this dispatch's own <owner>- into the real folder, line for line. Any other file in the spool is ignored.
Delivery in CI
A CI job delivers to a server or records nothing.
| Case | Result |
|---|---|
No logs.url approved by the default branch | none: "no server destination is configured for CI delivery" |
logs.url set, but a ${VAR} in logs.headers is empty or missing (a fork pull request, a missing secret) | none: "this job holds no delivery credential" |
| Both present | server, as above |
Vinaya warns once per process with the reason. The generated vinaya- workflow sets VINAYA_ and VINAYA_. An event written at process exit on a CI host records nothing.
When a run falls back to the folder
If an unattended run declared a logs.url that the default-branch rule refused, its events go to the folder, and the folder records why:
| Reason | Meaning | vinaya doctor shows |
|---|---|---|
anchor- | The default branch's config could not be read: offline, not signed in, or slower than 8 seconds. Events wait in the folder for vinaya log send. | info |
anchor-mismatch | The default branch was read and declares a different url. | warn |
The fallback is recorded once per process: one line on stderr, a line in the task's driver.log when the event names a task, and ~/. vinaya doctor shows the last one as [logs-fallback]. The plain default folder carries no reason.
Time limits
| Limit | Value | Covers |
|---|---|---|
| Context lookup | 3 seconds | The branch, repository and doctrine lookups for each event; the lookup is killed at the limit |
| Default-branch read | 8 seconds | Reading the default branch's logs value, once per process per repository |
Events at process exit
Writing an event is fire-and-forget. If a process calls exit before the write settles, the event is lost. So Vinaya waits for every pending write and send before an abrupt exit. The loop driver does this in its signal handlers, before it re-executes itself, and when it finishes.
For a process that exits straight after writing, Vinaya also has a synchronous write that appends the line before returning. If the destination is not resolved yet, that line goes to a fallback folder (never a server, and nothing in CI). It does not send anything; a later send or vinaya log send delivers it.
Never the forge
Events are never written to the forge: no Issue or pull-request comment, no CI artifact.
Configuration
| Key or variable | Effect |
|---|---|
logs.folder | Folder destination (absolute, outside the repository) |
logs.url, logs.headers | Server destination and its ingest headers (${VAR} filled from the environment) |
runtimeDir | Parent of the default folder |
VINAYA_ | Spool for a sandboxed dispatch |
VINAYA_ | Marks a caller as unattended (a CI job) |
GITHUB_ACTIONS | Marks the host as CI |
Commands
| Command | What it does |
|---|---|
vinaya log send | Moves folder files into the retry queue and sends it once |
vinaya doctor | Shows the destination ([logs]), where the credential comes from ([log-credential]) and the last fallback ([logs-fallback]) |