From 401dca07d0bd42778e7c1ec8b4b553b2fc121e08 Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Mon, 23 Mar 2026 04:09:51 +0530 Subject: [PATCH] feat(session): add worker health alongside state in ecc.session.v1 (#751) --- docs/SESSION-ADAPTER-CONTRACT.md | 10 +++- .../lib/session-adapters/canonical-session.js | 50 ++++++++++++++++++- tests/lib/session-adapters.test.js | 25 ++++++++-- 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/SESSION-ADAPTER-CONTRACT.md b/docs/SESSION-ADAPTER-CONTRACT.md index fbc213c0..49401ff9 100644 --- a/docs/SESSION-ADAPTER-CONTRACT.md +++ b/docs/SESSION-ADAPTER-CONTRACT.md @@ -42,6 +42,7 @@ Every adapter MUST return a JSON-serializable object with this top-level shape: "id": "seed-check", "label": "seed-check", "state": "running", + "health": "healthy", "branch": "feature/seed-check", "worktree": "/tmp/worktree", "runtime": { @@ -71,6 +72,9 @@ Every adapter MUST return a JSON-serializable object with this top-level shape: "workerCount": 1, "states": { "running": 1 + }, + "healths": { + "healthy": 1 } } } @@ -110,7 +114,8 @@ Every adapter MUST return a JSON-serializable object with this top-level shape: | --- | --- | --- | | `id` | string | Stable worker identifier in adapter scope | | `label` | string | Operator-facing label | -| `state` | string | Canonical worker state | +| `state` | string | Canonical worker state (lifecycle) | +| `health` | string | Canonical worker health (operational condition) | | `runtime` | object | Execution/runtime metadata | | `intent` | object | Why this worker/session exists | | `outputs` | object | Structured outcomes and checks | @@ -145,6 +150,7 @@ Every adapter MUST return a JSON-serializable object with this top-level shape: | --- | --- | --- | | `workerCount` | integer | MUST equal `workers.length` | | `states` | object | Count map derived from `workers[].state` | +| `healths` | object | Count map derived from `workers[].health` | ## Optional Fields @@ -189,6 +195,7 @@ degrade gracefully. - adding new optional nested fields - adding new adapter ids - adding new state string values +- adding new health string values - adding new artifact keys inside `workers[].artifacts` ### Requires a new schema version @@ -214,6 +221,7 @@ Every ECC session adapter MUST: documented nested objects. 5. Ensure `aggregates.workerCount === workers.length`. 6. Ensure `aggregates.states` matches the emitted worker states. +7. Ensure `aggregates.healths` matches the emitted worker health values. 7. Produce plain JSON-serializable values only. 8. Validate the canonical shape before persistence or downstream use. 9. Persist the normalized canonical snapshot through the session recording shim. diff --git a/scripts/lib/session-adapters/canonical-session.js b/scripts/lib/session-adapters/canonical-session.js index ae50afc9..4e0fe4f5 100644 --- a/scripts/lib/session-adapters/canonical-session.js +++ b/scripts/lib/session-adapters/canonical-session.js @@ -60,6 +60,35 @@ function ensureInteger(value, fieldPath) { } } +const STALE_THRESHOLD_MS = 5 * 60 * 1000; + +function parseUpdatedMs(updated) { + if (typeof updated !== 'string' || updated.length === 0) return null; + const ms = Date.parse(updated); + return Number.isNaN(ms) ? null : ms; +} + +function deriveWorkerHealth(rawWorker) { + const state = (rawWorker.status && rawWorker.status.state) || 'unknown'; + const completedStates = ['completed', 'succeeded', 'success', 'done']; + const failedStates = ['failed', 'error']; + + if (failedStates.includes(state)) return 'degraded'; + if (completedStates.includes(state)) return 'healthy'; + + if (state === 'running' || state === 'active') { + const pane = rawWorker.pane; + if (pane && pane.dead) return 'degraded'; + + const updatedMs = parseUpdatedMs(rawWorker.status && rawWorker.status.updated); + if (updatedMs === null) return 'stale'; + if (Date.now() - updatedMs > STALE_THRESHOLD_MS) return 'stale'; + return 'healthy'; + } + + return 'unknown'; +} + function buildAggregates(workers) { const states = workers.reduce((accumulator, worker) => { const state = worker.state || 'unknown'; @@ -67,9 +96,16 @@ function buildAggregates(workers) { return accumulator; }, {}); + const healths = workers.reduce((accumulator, worker) => { + const health = worker.health || 'unknown'; + accumulator[health] = (accumulator[health] || 0) + 1; + return accumulator; + }, {}); + return { workerCount: workers.length, - states + states, + healths }; } @@ -157,6 +193,7 @@ function validateCanonicalSnapshot(snapshot) { 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`); @@ -202,11 +239,20 @@ function validateCanonicalSnapshot(snapshot) { throw new Error('Canonical session snapshot requires aggregates.states to be an object'); } + if (!isObject(snapshot.aggregates.healths)) { + throw new Error('Canonical session snapshot requires aggregates.healths to be an object'); + } + for (const [state, count] of Object.entries(snapshot.aggregates.states)) { ensureString(state, 'aggregates.states key'); ensureInteger(count, `aggregates.states.${state}`); } + for (const [health, count] of Object.entries(snapshot.aggregates.healths)) { + ensureString(health, 'aggregates.healths key'); + ensureInteger(count, `aggregates.healths.${health}`); + } + return snapshot; } @@ -376,6 +422,7 @@ function normalizeDmuxSnapshot(snapshot, sourceTarget) { id: worker.workerSlug, label: worker.workerSlug, state: worker.status.state || 'unknown', + health: deriveWorkerHealth(worker), branch: worker.status.branch || null, worktree: worker.status.worktree || null, runtime: { @@ -431,6 +478,7 @@ function normalizeClaudeHistorySession(session, sourceTarget) { id: workerId, label: metadata.title || session.filename || workerId, state: 'recorded', + health: 'healthy', branch: metadata.branch || null, worktree: metadata.worktree || null, runtime: { diff --git a/tests/lib/session-adapters.test.js b/tests/lib/session-adapters.test.js index 529ba3fe..6895fb2f 100644 --- a/tests/lib/session-adapters.test.js +++ b/tests/lib/session-adapters.test.js @@ -59,7 +59,10 @@ test('dmux adapter normalizes orchestration snapshots into canonical form', () = const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); try { + const recentUpdated = new Date(Date.now() - 60000).toISOString(); + const adapter = createDmuxTmuxAdapter({ + loadStateStoreImpl: () => null, collectSessionSnapshotImpl: () => ({ sessionName: 'workflow-visual-proof', coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', @@ -85,7 +88,7 @@ test('dmux adapter normalizes orchestration snapshots into canonical form', () = workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check', status: { state: 'running', - updated: '2026-03-13T00:00:00Z', + updated: recentUpdated, branch: 'feature/seed-check', worktree: '/tmp/worktree', taskFile: '/tmp/task.md', @@ -125,6 +128,7 @@ test('dmux adapter normalizes orchestration snapshots into canonical form', () = assert.strictEqual(snapshot.session.state, 'active'); assert.strictEqual(snapshot.session.sourceTarget.type, 'session'); assert.strictEqual(snapshot.aggregates.workerCount, 1); + assert.strictEqual(snapshot.workers[0].health, 'healthy'); assert.strictEqual(snapshot.workers[0].runtime.kind, 'tmux-pane'); assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet'); assert.strictEqual(persisted.latest.session.state, 'active'); @@ -140,6 +144,7 @@ test('dmux adapter marks finished sessions as completed and records history', () try { const adapter = createDmuxTmuxAdapter({ + loadStateStoreImpl: () => null, collectSessionSnapshotImpl: () => ({ sessionName: 'workflow-visual-proof', coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', @@ -213,6 +218,8 @@ test('dmux adapter marks finished sessions as completed and records history', () assert.strictEqual(snapshot.session.state, 'completed'); assert.strictEqual(snapshot.aggregates.states.completed, 2); + assert.strictEqual(snapshot.workers[0].health, 'healthy'); + assert.strictEqual(snapshot.workers[1].health, 'healthy'); assert.strictEqual(persisted.latest.session.state, 'completed'); assert.strictEqual(persisted.history.length, 1); } finally { @@ -225,6 +232,7 @@ test('fallback recording does not append duplicate history entries for unchanged try { const adapter = createDmuxTmuxAdapter({ + loadStateStoreImpl: () => null, collectSessionSnapshotImpl: () => ({ sessionName: 'workflow-visual-proof', coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', @@ -314,7 +322,10 @@ test('claude-history adapter loads the latest recorded session', () => { try { withHome(homeDir, () => { - const adapter = createClaudeHistoryAdapter({ recordingDir }); + const adapter = createClaudeHistoryAdapter({ + loadStateStoreImpl: () => null, + recordingDir + }); const snapshot = adapter.open('claude:latest').getSnapshot(); const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir }); const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); @@ -361,6 +372,7 @@ test('adapter registry routes plan files to dmux and explicit claude targets to const registry = createAdapterRegistry({ adapters: [ createDmuxTmuxAdapter({ + loadStateStoreImpl: () => null, collectSessionSnapshotImpl: () => ({ sessionName: 'workflow-visual-proof', coordinationDir: path.join(repoRoot, '.claude', 'orchestration', 'workflow-visual-proof'), @@ -374,7 +386,7 @@ test('adapter registry routes plan files to dmux and explicit claude targets to workers: [] }) }), - createClaudeHistoryAdapter() + createClaudeHistoryAdapter({ loadStateStoreImpl: () => null }) ] }); @@ -412,6 +424,7 @@ test('adapter registry resolves structured target types into the correct adapter const registry = createAdapterRegistry({ adapters: [ createDmuxTmuxAdapter({ + loadStateStoreImpl: () => null, collectSessionSnapshotImpl: () => ({ sessionName: 'workflow-typed-proof', coordinationDir: path.join(repoRoot, '.claude', 'orchestration', 'workflow-typed-proof'), @@ -425,7 +438,7 @@ test('adapter registry resolves structured target types into the correct adapter workers: [] }) }), - createClaudeHistoryAdapter() + createClaudeHistoryAdapter({ loadStateStoreImpl: () => null }) ] }); @@ -514,6 +527,7 @@ test('persistence only falls back when the state-store module is missing', () => id: 'a1b2c3d4', label: 'Session Review', state: 'recorded', + health: 'healthy', branch: null, worktree: null, runtime: { @@ -541,6 +555,9 @@ test('persistence only falls back when the state-store module is missing', () => workerCount: 1, states: { recorded: 1 + }, + healths: { + healthy: 1 } } };