affaan-m/ECC · error · Error

Canonical session snapshot requires workers[${index}] to be

Error message

Canonical session snapshot requires workers[${index}] to be an object

What it means

Thrown by validateCanonicalSnapshot() while iterating snapshot.workers: an entry at position `index` failed the isObject() check (it was null, an array, or a primitive). The canonical schema (ecc.session.v1) requires every workers[] entry to be a plain object with id/label/state/health/runtime/intent/outputs/artifacts. Validation runs both on hand-supplied snapshots and as a self-check inside every normalize*Session builder before persist.

Source

Thrown at scripts/lib/session-adapters/canonical-session.js:196

  ensureString(snapshot.session.id, 'session.id');
  ensureString(snapshot.session.kind, 'session.kind');
  ensureString(snapshot.session.state, 'session.state');
  ensureOptionalString(snapshot.session.repoRoot, 'session.repoRoot');

  if (!isObject(snapshot.session.sourceTarget)) {
    throw new Error('Canonical session snapshot requires session.sourceTarget to be an object');
  }

  ensureString(snapshot.session.sourceTarget.type, 'session.sourceTarget.type');
  ensureString(snapshot.session.sourceTarget.value, 'session.sourceTarget.value');

  if (!Array.isArray(snapshot.workers)) {
    throw new Error('Canonical session snapshot requires workers to be an array');
  }

  snapshot.workers.forEach((worker, index) => {
    if (!isObject(worker)) {
      throw new Error(`Canonical session snapshot requires workers[${index}] to be an object`);
    }

    ensureString(worker.id, `workers[${index}].id`);
    ensureString(worker.label, `workers[${index}].label`);
    ensureString(worker.state, `workers[${index}].state`);
    ensureString(worker.health, `workers[${index}].health`);
    ensureOptionalString(worker.branch, `workers[${index}].branch`);
    ensureOptionalString(worker.worktree, `workers[${index}].worktree`);

    if (!isObject(worker.runtime)) {
      throw new Error(`Canonical session snapshot requires workers[${index}].runtime to be an object`);
    }

    ensureString(worker.runtime.kind, `workers[${index}].runtime.kind`);
    ensureOptionalString(worker.runtime.command, `workers[${index}].runtime.command`);
    ensureBoolean(worker.runtime.active, `workers[${index}].runtime.active`);
    ensureBoolean(worker.runtime.dead, `workers[${index}].runtime.dead`);

View on GitHub (pinned to 01e15490f0)

Solutions

  1. Inspect snapshot.workers[index]: if null/array/primitive, either drop the entry or rebuild it as an object matching the worker shape (id,label,state,health,runtime,intent,outputs,artifacts).
  2. If the snapshot came from a recording file, open the JSON and confirm workers[] entries are objects; re-record from the live adapter if corrupted.
  3. When building a custom adapter, filter workers before validation: workers = rawWorkers.filter(w => w && typeof w === 'object').
  4. Reproduce with a minimal snapshot and print JSON.stringify(snapshot.workers[index], null, 2) to see exactly what was passed.

Example fix

// before
const snapshot = {
  schemaVersion: 'ecc.session.v1',
  adapterId: 'custom',
  session: { id: 's1', kind: 'custom', state: 'active', sourceTarget: { type: 'custom', value: 's1' } },
  workers: [null], // throws at index 0
  aggregates: { workerCount: 1, states: {}, healths: {} }
};

// after: drop or materialize the placeholder
const workers = rawWorkers
  .filter(w => w && typeof w === 'object')
  .map(materializeWorker);
const snapshot = {
  schemaVersion: 'ecc.session.v1',
  adapterId: 'custom',
  session: { id: 's1', kind: 'custom', state: 'active', sourceTarget: { type: 'custom', value: 's1' } },
  workers,
  aggregates: buildAggregates(workers)
};
Defensive patterns

Strategy: validation

Validate before calling

function sanitizeWorkers(workers) {
  if (!Array.isArray(workers)) return [];
  return workers.filter(w => w !== null && typeof w === 'object' && !Array.isArray(w));
}
// before persisting:
snapshot.workers = sanitizeWorkers(snapshot.workers);
snapshot.aggregates = buildAggregates(snapshot.workers);

Type guard

function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
function isWorkerEntry(w) {
  return isPlainObject(w)
    && typeof w.id === 'string' && typeof w.label === 'string'
    && isPlainObject(w.runtime) && isPlainObject(w.intent)
    && isPlainObject(w.outputs) && isPlainObject(w.artifacts);
}

Try / catch

try { validateCanonicalSnapshot(snapshot); }
catch (err) {
  if (err.message.includes('workers[')) {
    // log the failing index from the message, sanitize, and rebuild aggregates
    snapshot.workers = (snapshot.workers||[]).filter(isWorkerEntry);
    snapshot.aggregates = buildAggregates(snapshot.workers);
    validateCanonicalSnapshot(snapshot);
  } else throw err;
}

Prevention

When it happens

Trigger: Calling validateCanonicalSnapshot(snapshot) or persistCanonicalSnapshot(snapshot) where snapshot.workers contains a non-object element (e.g. workers:[null] or workers:["w1"]). Reading a recorded canonical snapshot JSON from disk whose workers array was hand-edited or written by an older adapter that emitted null placeholders for dead panes.

Common situations: A future or custom adapter maps a missing dmux pane to null in the workers array instead of skipping it. A user reloads a persisted recording that was truncated/corrupted on write. Snapshot is assembled manually from partial data and a worker slot is left as a placeholder string.

Related errors


AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13). Data as JSON: /api/errors/c3eaf1c7886760d0. Report an issue: GitHub.