Skip to content

Commit 79e98c2

Browse files
Surface unhealthy-age rollup on dw system:operator-metrics
Renders the workflow-v2 unhealthy-age rollup pinned by the Phase 6 rollout-safety contract on `operator_metrics.tasks.oldest_unhealthy_at` and `operator_metrics.tasks.max_unhealthy_age_ms` so HTTP-only operators can read the worst-case duplicate-risk task age (the earliest of `oldest_dispatch_failed_at`, `oldest_claim_failed_at`, `oldest_dispatch_overdue_since`, `oldest_lease_expired_at` and the largest age in milliseconds across them) directly from `dw system:operator-metrics` without taking a max over four separate per-path age fields themselves. `OperatorMetricsCommand::renderTasks()` now emits two new rows right after the existing "Unhealthy (duplicate-risk roll-up)" summary line and before the per-path age detail rows: Oldest unhealthy age: <ms> ms Oldest unhealthy at: <ISO> Both rows degrade gracefully when the snapshot predates the contract: the renderer omits each row when its key is absent and the existing per-path age rendering still surfaces individual lease, ready-due, dispatch-overdue, claim-failed, and dispatch-failed ages. Pins `tasks.oldest_unhealthy_at` and `tasks.max_unhealthy_age_ms` on `schemas/output/operator-metrics.schema.json` next to the existing unhealthy/per-path age keys, and adds `test_operator_metrics_schema_pins_unhealthy_age_keys` and `test_operator_metrics_command_omits_unhealthy_age_when_snapshot_predates_contract` plus assertions on the existing render smoke test to guard the schema declaration, the renderer, and the renderer fallback.
1 parent 9c557ee commit 79e98c2

3 files changed

Lines changed: 54 additions & 1 deletion

File tree

schemas/output/operator-metrics.schema.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
"max_claim_failed_age_ms": { "type": ["integer", "null"] },
4444
"oldest_dispatch_failed_at": { "type": ["string", "null"] },
4545
"max_dispatch_failed_age_ms": { "type": ["integer", "null"] },
46-
"unhealthy": { "type": ["integer", "null"] }
46+
"unhealthy": { "type": ["integer", "null"] },
47+
"oldest_unhealthy_at": { "type": ["string", "null"] },
48+
"max_unhealthy_age_ms": { "type": ["integer", "null"] }
4749
},
4850
"additionalProperties": true
4951
},

src/Commands/SystemCommand/OperatorMetricsCommand.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ private function renderTasks(OutputInterface $output, array $tasks): void
136136
(int) ($tasks['dispatch_overdue'] ?? 0),
137137
(int) ($tasks['lease_expired'] ?? 0),
138138
));
139+
if (array_key_exists('max_unhealthy_age_ms', $tasks)) {
140+
$output->writeln(sprintf(
141+
' Oldest unhealthy age: %d ms',
142+
(int) ($tasks['max_unhealthy_age_ms'] ?? 0),
143+
));
144+
}
145+
if (is_string($tasks['oldest_unhealthy_at'] ?? null)) {
146+
$output->writeln(sprintf(' Oldest unhealthy at: %s', $tasks['oldest_unhealthy_at']));
147+
}
139148
if (array_key_exists('max_lease_expired_age_ms', $tasks)) {
140149
$output->writeln(sprintf(
141150
' Oldest lease-expired age: %d ms',

tests/Commands/SystemCommandTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,8 @@ public function test_operator_metrics_command_renders_rollout_safety_signals():
516516
'Unhealthy (duplicate-risk roll-up): 11 (dispatch failed 2, claim failed 3, dispatch overdue 4, lease expired 2)',
517517
$display,
518518
);
519+
self::assertStringContainsString('Oldest unhealthy age: 135000 ms', $display);
520+
self::assertStringContainsString('Oldest unhealthy at: 2026-04-24T11:27:45Z', $display);
519521
self::assertStringContainsString('Oldest lease-expired age: 95000 ms', $display);
520522
self::assertStringContainsString('Oldest lease expired at: 2026-04-24T11:28:25Z', $display);
521523
self::assertStringContainsString('Oldest ready-due age: 15000 ms', $display);
@@ -701,6 +703,44 @@ public function test_operator_metrics_schema_pins_dispatch_failed_age_keys(): vo
701703
self::assertSame(['integer', 'null'], $tasks['max_dispatch_failed_age_ms']['type']);
702704
}
703705

706+
public function test_operator_metrics_schema_pins_unhealthy_age_keys(): void
707+
{
708+
$schema = json_decode(
709+
(string) file_get_contents(__DIR__.'/../../schemas/output/operator-metrics.schema.json'),
710+
true,
711+
flags: JSON_THROW_ON_ERROR,
712+
);
713+
714+
$tasks = $schema['properties']['operator_metrics']['properties']['tasks']['properties'];
715+
716+
self::assertSame(['string', 'null'], $tasks['oldest_unhealthy_at']['type']);
717+
self::assertSame(['integer', 'null'], $tasks['max_unhealthy_age_ms']['type']);
718+
}
719+
720+
public function test_operator_metrics_command_omits_unhealthy_age_when_snapshot_predates_contract(): void
721+
{
722+
$payload = self::operatorMetricsPayload();
723+
unset(
724+
$payload['operator_metrics']['tasks']['oldest_unhealthy_at'],
725+
$payload['operator_metrics']['tasks']['max_unhealthy_age_ms'],
726+
);
727+
728+
$command = new OperatorMetricsCommand();
729+
$command->setServerClient(new SystemFakeClient($payload));
730+
731+
$tester = new CommandTester($command);
732+
self::assertSame(Command::SUCCESS, $tester->execute([]));
733+
734+
$display = $tester->getDisplay();
735+
736+
self::assertStringNotContainsString('Oldest unhealthy age:', $display);
737+
self::assertStringNotContainsString('Oldest unhealthy at:', $display);
738+
self::assertStringContainsString(
739+
'Unhealthy (duplicate-risk roll-up): 11',
740+
$display,
741+
);
742+
}
743+
704744
public function test_operator_metrics_schema_pins_run_wait_age_keys(): void
705745
{
706746
$schema = json_decode(
@@ -982,6 +1022,8 @@ private static function operatorMetricsPayload(): array
9821022
'oldest_dispatch_failed_at' => '2026-04-24T11:27:45Z',
9831023
'max_dispatch_failed_age_ms' => 135000,
9841024
'unhealthy' => 11,
1025+
'oldest_unhealthy_at' => '2026-04-24T11:27:45Z',
1026+
'max_unhealthy_age_ms' => 135000,
9851027
],
9861028
'backlog' => [
9871029
'runnable_tasks' => 7,

0 commit comments

Comments
 (0)