mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat(session): add worker health alongside state in ecc.session.v1 (#751)
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user