feat(session): add worker health alongside state in ecc.session.v1 (#751)

This commit is contained in:
Neha Prasad
2026-03-23 04:09:51 +05:30
committed by GitHub
parent 4df960c9d5
commit 401dca07d0
3 changed files with 79 additions and 6 deletions

View File

@@ -42,6 +42,7 @@ Every adapter MUST return a JSON-serializable object with this top-level shape:
"id": "seed-check", "id": "seed-check",
"label": "seed-check", "label": "seed-check",
"state": "running", "state": "running",
"health": "healthy",
"branch": "feature/seed-check", "branch": "feature/seed-check",
"worktree": "/tmp/worktree", "worktree": "/tmp/worktree",
"runtime": { "runtime": {
@@ -71,6 +72,9 @@ Every adapter MUST return a JSON-serializable object with this top-level shape:
"workerCount": 1, "workerCount": 1,
"states": { "states": {
"running": 1 "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 | | `id` | string | Stable worker identifier in adapter scope |
| `label` | string | Operator-facing label | | `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 | | `runtime` | object | Execution/runtime metadata |
| `intent` | object | Why this worker/session exists | | `intent` | object | Why this worker/session exists |
| `outputs` | object | Structured outcomes and checks | | `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` | | `workerCount` | integer | MUST equal `workers.length` |
| `states` | object | Count map derived from `workers[].state` | | `states` | object | Count map derived from `workers[].state` |
| `healths` | object | Count map derived from `workers[].health` |
## Optional Fields ## Optional Fields
@@ -189,6 +195,7 @@ degrade gracefully.
- adding new optional nested fields - adding new optional nested fields
- adding new adapter ids - adding new adapter ids
- adding new state string values - adding new state string values
- adding new health string values
- adding new artifact keys inside `workers[].artifacts` - adding new artifact keys inside `workers[].artifacts`
### Requires a new schema version ### Requires a new schema version
@@ -214,6 +221,7 @@ Every ECC session adapter MUST:
documented nested objects. documented nested objects.
5. Ensure `aggregates.workerCount === workers.length`. 5. Ensure `aggregates.workerCount === workers.length`.
6. Ensure `aggregates.states` matches the emitted worker states. 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. 7. Produce plain JSON-serializable values only.
8. Validate the canonical shape before persistence or downstream use. 8. Validate the canonical shape before persistence or downstream use.
9. Persist the normalized canonical snapshot through the session recording shim. 9. Persist the normalized canonical snapshot through the session recording shim.

View File

@@ -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) { function buildAggregates(workers) {
const states = workers.reduce((accumulator, worker) => { const states = workers.reduce((accumulator, worker) => {
const state = worker.state || 'unknown'; const state = worker.state || 'unknown';
@@ -67,9 +96,16 @@ function buildAggregates(workers) {
return accumulator; return accumulator;
}, {}); }, {});
const healths = workers.reduce((accumulator, worker) => {
const health = worker.health || 'unknown';
accumulator[health] = (accumulator[health] || 0) + 1;
return accumulator;
}, {});
return { return {
workerCount: workers.length, workerCount: workers.length,
states states,
healths
}; };
} }
@@ -157,6 +193,7 @@ function validateCanonicalSnapshot(snapshot) {
ensureString(worker.id, `workers[${index}].id`); ensureString(worker.id, `workers[${index}].id`);
ensureString(worker.label, `workers[${index}].label`); ensureString(worker.label, `workers[${index}].label`);
ensureString(worker.state, `workers[${index}].state`); ensureString(worker.state, `workers[${index}].state`);
ensureString(worker.health, `workers[${index}].health`);
ensureOptionalString(worker.branch, `workers[${index}].branch`); ensureOptionalString(worker.branch, `workers[${index}].branch`);
ensureOptionalString(worker.worktree, `workers[${index}].worktree`); 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'); 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)) { for (const [state, count] of Object.entries(snapshot.aggregates.states)) {
ensureString(state, 'aggregates.states key'); ensureString(state, 'aggregates.states key');
ensureInteger(count, `aggregates.states.${state}`); 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; return snapshot;
} }
@@ -376,6 +422,7 @@ function normalizeDmuxSnapshot(snapshot, sourceTarget) {
id: worker.workerSlug, id: worker.workerSlug,
label: worker.workerSlug, label: worker.workerSlug,
state: worker.status.state || 'unknown', state: worker.status.state || 'unknown',
health: deriveWorkerHealth(worker),
branch: worker.status.branch || null, branch: worker.status.branch || null,
worktree: worker.status.worktree || null, worktree: worker.status.worktree || null,
runtime: { runtime: {
@@ -431,6 +478,7 @@ function normalizeClaudeHistorySession(session, sourceTarget) {
id: workerId, id: workerId,
label: metadata.title || session.filename || workerId, label: metadata.title || session.filename || workerId,
state: 'recorded', state: 'recorded',
health: 'healthy',
branch: metadata.branch || null, branch: metadata.branch || null,
worktree: metadata.worktree || null, worktree: metadata.worktree || null,
runtime: { runtime: {

View File

@@ -59,7 +59,10 @@ test('dmux adapter normalizes orchestration snapshots into canonical form', () =
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-')); const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-recordings-'));
try { try {
const recentUpdated = new Date(Date.now() - 60000).toISOString();
const adapter = createDmuxTmuxAdapter({ const adapter = createDmuxTmuxAdapter({
loadStateStoreImpl: () => null,
collectSessionSnapshotImpl: () => ({ collectSessionSnapshotImpl: () => ({
sessionName: 'workflow-visual-proof', sessionName: 'workflow-visual-proof',
coordinationDir: '/tmp/.claude/orchestration/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', workerDir: '/tmp/.claude/orchestration/workflow-visual-proof/seed-check',
status: { status: {
state: 'running', state: 'running',
updated: '2026-03-13T00:00:00Z', updated: recentUpdated,
branch: 'feature/seed-check', branch: 'feature/seed-check',
worktree: '/tmp/worktree', worktree: '/tmp/worktree',
taskFile: '/tmp/task.md', 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.state, 'active');
assert.strictEqual(snapshot.session.sourceTarget.type, 'session'); assert.strictEqual(snapshot.session.sourceTarget.type, 'session');
assert.strictEqual(snapshot.aggregates.workerCount, 1); 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].runtime.kind, 'tmux-pane');
assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet'); assert.strictEqual(snapshot.workers[0].outputs.remainingRisks[0], 'No screenshot yet');
assert.strictEqual(persisted.latest.session.state, 'active'); assert.strictEqual(persisted.latest.session.state, 'active');
@@ -140,6 +144,7 @@ test('dmux adapter marks finished sessions as completed and records history', ()
try { try {
const adapter = createDmuxTmuxAdapter({ const adapter = createDmuxTmuxAdapter({
loadStateStoreImpl: () => null,
collectSessionSnapshotImpl: () => ({ collectSessionSnapshotImpl: () => ({
sessionName: 'workflow-visual-proof', sessionName: 'workflow-visual-proof',
coordinationDir: '/tmp/.claude/orchestration/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.session.state, 'completed');
assert.strictEqual(snapshot.aggregates.states.completed, 2); 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.latest.session.state, 'completed');
assert.strictEqual(persisted.history.length, 1); assert.strictEqual(persisted.history.length, 1);
} finally { } finally {
@@ -225,6 +232,7 @@ test('fallback recording does not append duplicate history entries for unchanged
try { try {
const adapter = createDmuxTmuxAdapter({ const adapter = createDmuxTmuxAdapter({
loadStateStoreImpl: () => null,
collectSessionSnapshotImpl: () => ({ collectSessionSnapshotImpl: () => ({
sessionName: 'workflow-visual-proof', sessionName: 'workflow-visual-proof',
coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof', coordinationDir: '/tmp/.claude/orchestration/workflow-visual-proof',
@@ -314,7 +322,10 @@ test('claude-history adapter loads the latest recorded session', () => {
try { try {
withHome(homeDir, () => { withHome(homeDir, () => {
const adapter = createClaudeHistoryAdapter({ recordingDir }); const adapter = createClaudeHistoryAdapter({
loadStateStoreImpl: () => null,
recordingDir
});
const snapshot = adapter.open('claude:latest').getSnapshot(); const snapshot = adapter.open('claude:latest').getSnapshot();
const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir }); const recordingPath = getFallbackSessionRecordingPath(snapshot, { recordingDir });
const persisted = JSON.parse(fs.readFileSync(recordingPath, 'utf8')); 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({ const registry = createAdapterRegistry({
adapters: [ adapters: [
createDmuxTmuxAdapter({ createDmuxTmuxAdapter({
loadStateStoreImpl: () => null,
collectSessionSnapshotImpl: () => ({ collectSessionSnapshotImpl: () => ({
sessionName: 'workflow-visual-proof', sessionName: 'workflow-visual-proof',
coordinationDir: path.join(repoRoot, '.claude', 'orchestration', '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: [] workers: []
}) })
}), }),
createClaudeHistoryAdapter() createClaudeHistoryAdapter({ loadStateStoreImpl: () => null })
] ]
}); });
@@ -412,6 +424,7 @@ test('adapter registry resolves structured target types into the correct adapter
const registry = createAdapterRegistry({ const registry = createAdapterRegistry({
adapters: [ adapters: [
createDmuxTmuxAdapter({ createDmuxTmuxAdapter({
loadStateStoreImpl: () => null,
collectSessionSnapshotImpl: () => ({ collectSessionSnapshotImpl: () => ({
sessionName: 'workflow-typed-proof', sessionName: 'workflow-typed-proof',
coordinationDir: path.join(repoRoot, '.claude', 'orchestration', '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: [] workers: []
}) })
}), }),
createClaudeHistoryAdapter() createClaudeHistoryAdapter({ loadStateStoreImpl: () => null })
] ]
}); });
@@ -514,6 +527,7 @@ test('persistence only falls back when the state-store module is missing', () =>
id: 'a1b2c3d4', id: 'a1b2c3d4',
label: 'Session Review', label: 'Session Review',
state: 'recorded', state: 'recorded',
health: 'healthy',
branch: null, branch: null,
worktree: null, worktree: null,
runtime: { runtime: {
@@ -541,6 +555,9 @@ test('persistence only falls back when the state-store module is missing', () =>
workerCount: 1, workerCount: 1,
states: { states: {
recorded: 1 recorded: 1
},
healths: {
healthy: 1
} }
} }
}; };