> ## Documentation Index
> Fetch the complete documentation index at: https://docs.obversa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Safe node attempts

> Run one bounded engine turn with explicit identity, results, usage, and handled-exit cleanup.

An engine-backed node attempt is one fresh engine call doing one piece of work.
Command adapters use one fresh CLI process; data-only attempts use no engine
process. The runtime records what was requested, what actually ran, what came back,
and whether token usage was reported or unknown.

The graph executor connects attempts to graph dispatch. Hosts coordinate
checks against installed CLIs and process cleanup after runner death.

## Run the example

From an Obversa checkout, run:

```bash theme={null}
pnpm example:attempt
```

The report prints the stub executable paths relative to the temporary directory.
The exported `attemptReport` keeps the absolute paths from the adapter results.
The example uses local scripted executables. It does not call a model or the
network. Grok returns a native structured result. OpenCode returns one marked
text part, which the job-owned parser reads. Both adapter results pass through
the public result validator before their final parts are used. OpenCode
deliberately omits a valid usage receipt, so the runtime keeps its usage as
`unknown` instead of zero.

## Source

```ts theme={null}
import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';

import {
  finalResultPart,
  validateAgentResult,
  type AgentRequest,
  type AgentResultPart,
  type JsonValue,
} from '@obversa/runtime';
import { GrokCliEngine } from '@obversa/engine-grok-cli';
import { OpenCodeCliEngine } from '@obversa/engine-opencode-cli';

const STRUCTURED_RESULT_MARKER = 'OBVERSA_STRUCTURED_RESULT_V1\n';
const RESULT_SCHEMA = {
  type: 'object',
  properties: { answer: { type: 'number' } },
  required: ['answer'],
  additionalProperties: false,
} as const;

const GROK_FIXTURE = `#!/usr/bin/env node
process.stdout.write(JSON.stringify({
  text: '{"answer":42}',
  stopReason: 'end_turn',
  sessionId: 'example-session',
  requestId: 'example-request',
  usage: {
    input_tokens: 2,
    output_tokens: 5,
    cache_read_input_tokens: 0,
    cache_creation_input_tokens: 0
  },
  modelUsage: {
    'grok-4-example': {
      inputTokens: 2,
      outputTokens: 5,
      cacheReadInputTokens: 0,
      modelCalls: 1
    }
  },
  structuredOutput: { answer: 42 }
}, null, 2) + '\\n');
`;

const OPENCODE_FIXTURE = `#!/usr/bin/env node
for await (const chunk of process.stdin) void chunk;
const base = {
  timestamp: 1777777777777,
  sessionID: 'example-session'
};
const emit = (type, part) => {
  process.stdout.write(JSON.stringify({ ...base, type, part }) + '\\n');
};
emit('text', {
  id: 'example-result',
  sessionID: 'example-session',
  messageID: 'example-message',
  type: 'text',
  text: 'OBVERSA_STRUCTURED_RESULT_V1\\n{"answer":42}',
  time: { start: 1, end: 2 }
});
emit('step_finish', {
  id: 'example-finish',
  sessionID: 'example-session',
  messageID: 'example-message',
  type: 'step-finish',
  reason: 'stop',
  cost: 0,
  tokens: {}
});
`;

function request(
  directory: string,
  model: string,
  jsonSchema: JsonValue,
): AgentRequest {
  return {
    prompt: 'Return the structured answer.',
    system: 'Follow the result contract.',
    model,
    jsonSchema,
    tools: [],
    allowedTools: [],
    cwd: directory,
    workspaceMode: 'none',
    leaf: true,
    timeoutMs: 5_000,
    timeoutGraceMs: 200,
    maxOutputBytes: 64 * 1_024,
    maxMemoryBytes: 256 * 1_024 * 1_024,
  };
}

function nativeValue(part: AgentResultPart): JsonValue {
  assert.equal(part.kind, 'structured');
  if (part.kind !== 'structured') throw new TypeError('Expected a structured result');
  return part.value;
}

function parsedValue(part: AgentResultPart): JsonValue {
  assert.equal(part.kind, 'assistant');
  if (part.kind !== 'assistant') throw new TypeError('Expected an assistant result');
  assert.ok(part.text.startsWith(STRUCTURED_RESULT_MARKER));
  return JSON.parse(part.text.slice(STRUCTURED_RESULT_MARKER.length)) as JsonValue;
}

const directory = await mkdtemp(join(tmpdir(), 'obversa-safe-attempt-'));
const grokExecutable = join(directory, 'grok-fixture.mjs');
const openCodeExecutable = join(directory, 'opencode-fixture.mjs');
let report;

try {
  await writeFile(grokExecutable, GROK_FIXTURE);
  await writeFile(openCodeExecutable, OPENCODE_FIXTURE);
  await chmod(grokExecutable, 0o700);
  await chmod(openCodeExecutable, 0o700);

  const grok = validateAgentResult(await new GrokCliEngine({
    executable: grokExecutable,
    version: '1.0.5',
    identity: { provider: 'xai', modelFamily: 'grok-4' },
    permissionMode: 'dontAsk',
  }).run(
    request(directory, 'grok-4-example', RESULT_SCHEMA),
    () => {},
    new AbortController().signal,
  ));

  const opencode = validateAgentResult(await new OpenCodeCliEngine({
    executable: openCodeExecutable,
    version: '1.18.23',
    identity: { provider: 'opencode', modelFamily: null },
  }).run(
    request(directory, 'opencode/x-preview-f-free', RESULT_SCHEMA),
    () => {},
    new AbortController().signal,
  ));

  const grokFinal = nativeValue(finalResultPart(grok));
  const openCodeFinal = parsedValue(finalResultPart(opencode));
  assert.deepEqual(grokFinal, { answer: 42 });
  assert.deepEqual(openCodeFinal, { answer: 42 });
  assert.equal(opencode.usage.kind, 'unknown');

  report = {
    grok: {
      requested: grok.requested,
      effective: grok.effective,
      final: grokFinal,
      usage: grok.usage.kind,
    },
    opencode: {
      requested: opencode.requested,
      effective: opencode.effective,
      final: openCodeFinal,
      usage: opencode.usage.kind,
    },
  };
} finally {
  await rm(directory, { recursive: true, force: true });
}

assert.ok(report);
export const attemptReport = {
  ...report,
  temporaryDirectoryRemoved: !existsSync(directory),
};
console.log(JSON.stringify(attemptReport, (key, value: unknown) => (
  key === 'executable' && typeof value === 'string'
    ? relative(directory, value)
    : value
), 2));
```

The output records both requested and effective identities. If a CLI reports a
different model, the two records stay separate instead of hiding the change.
Each identity records the adapter, adapter version, provider, model family,
model, capabilities, and `executable`. Command adapters record the absolute
path they selected. An engine with no child executable uses `null`. The path
identifies the selected wrapper, not its resolved target or file digest. When
no Claude or Codex binary is configured, those plugins search the inherited
`PATH` once and record the absolute path they select.
`temporaryDirectoryRemoved` covers only this example's fixture files. The
package test suite separately checks child-process cleanup on handled exits.

## Headless process markers

Command adapters set three environment variables before they start a process:

* **`OBVERSA_HEADLESS=1`** tells hooks and child tools that the attempt is
  unattended, so they must not open interactive or desktop prompts.
* **`OBVERSA_RUN_ID`** identifies the run that owns the process.
* **`OBVERSA_ATTEMPT_ID`** identifies the exact node attempt and lets cleanup
  find descendants that detach from their parent process.

Child processes inherit these values unless they replace their environment.

## Keep results and usage honest

A successful result has ordered parts and exactly one final part. A stopped or
truncated turn can keep the parts it produced, including no parts at all, but
it remains a failed attempt.

Usage has two states. `reported` means the engine supplied a valid receipt.
`unknown` means it did not. Unknown usage never becomes a fake zero.

## Declare access before the process starts

`tools` lists the built-in capabilities the CLI may expose. `allowedTools`
holds the narrower permission rules approved by the host. The selected adapter
rejects a rule it cannot express before it starts the process.

The example uses `workspaceMode: 'none'` and exposes no tools. An evidence-only
job can use `workspaceMode: 'read'` with declared read tools. A writing job must
name its write access and gets a separate scratch directory for temporary data.

An action decision is made before an effect. `allow` runs it, `wait` pauses it,
and `deny` records a refusal without running it.

## Keep fallback and cleanup visible

One attempt can use one declared fallback after a model becomes unavailable.
The failed model stays in the attempt record. Both lanes share one clock, so a
fallback receives only the time left by the first lane. The graph executor uses
durable events to keep later graph work away from that model.

`timeoutMs` is the work deadline. At that point a command adapter starts
stopping its process tree. `timeoutGraceMs` is teardown time: the adapter asks
the processes to stop, waits for that grace, then force-stops any that remain.
The grace does not start more model work.

A marked final result returned inside the final-result deadline stays separate
from a later transport failure. If process cleanup makes the adapter return
after that deadline, the attempt fails with `TIMEOUT` and its parsed `result`
stays `null`. The record still keeps the returned parts, reported usage,
effective engine, and transport failure as evidence. After the deadline, the
runtime waits up to seven more seconds for that cleanup evidence. This fixed
wait cannot turn the attempt into a success.

The command-adapter kit cleans its process tree on normal completion, timeout,
abort, and other handled exits. The runtime waits for that cleanup before it
records the attempt. Recovery after the supervising process dies, and checks
against installed CLIs, are separate host responsibilities.
