Skip to content

Commit 7ec5aae

Browse files
Cite v2 execution-semantics contract in operator run diagnostics
Extend Waterline RunDiagnostics with a guidance field that describes each diagnostic in the vocabulary frozen by the workflow v2 execution-guarantees and idempotency contract. Activity retries are described as at-least-once with activity_execution_id as the recommended idempotency key; workflow-task failures are described as deterministic replay, not duplicate application execution; history budget and continue-as-new guidance names workflow_run_id and workflow_instance_id; and condition-wait guidance names signal, update, timer, and cancel as the durable resume sources. Surface the guidance line in the run-detail diagnostics banner so operators see the contract framing next to every diagnostic. Pin the alignment with a new unit test that asserts every GUIDANCE entry carries the required semantics terms, and extend the existing feature test to assert the guidance field is present on every emitted diagnostic.
1 parent a429789 commit 7ec5aae

4 files changed

Lines changed: 236 additions & 0 deletions

File tree

app/Support/RunDiagnostics.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,45 @@ class RunDiagnostics
1717

1818
private const HISTORY_BUDGET_WARNING_RATIO = 0.8;
1919

20+
/**
21+
* Operator-facing guidance strings aligned with the v2 execution-semantics
22+
* contract frozen in workflow@docs/architecture/execution-guarantees.md.
23+
* Each string names the semantics (at-least-once, deterministic replay,
24+
* exactly-once at the durable state layer) and, where applicable, the
25+
* framework idempotency surface an operator should reason about when the
26+
* diagnostic fires. The matching codes are pinned by
27+
* tests/Unit/Support/V2DiagnosticsExecutionContractAlignmentTest.php;
28+
* changes here must update that test in the same change.
29+
*/
30+
public const GUIDANCE = [
31+
'activity_repeated_failure' =>
32+
'Activity attempts are at-least-once. Every retry shares the same '
33+
. 'activity_execution_id, which is the recommended idempotency key '
34+
. 'for external calls and conditional database writes.',
35+
'activity_heartbeat_timeout_not_effective' =>
36+
'Heartbeats renew the activity attempt lease. When heartbeat_timeout '
37+
. 'is greater than or equal to start_to_close_timeout the start-to-close '
38+
. 'timeout closes the attempt first and the heartbeat timeout never '
39+
. 'fires — redelivery will not kick in on a stalled worker.',
40+
'activity_unbounded_retry_policy' =>
41+
'Activity attempts are at-least-once. Without a max_attempts ceiling, '
42+
. 'only a non-retryable error or a timeout ends the retry loop, so '
43+
. 'the activity_execution_id can keep producing new attempts.',
44+
'workflow_task_repeated_failure' =>
45+
'Workflow tasks are replayed deterministically, not retried against '
46+
. 'application logic. Repeated workflow-task failures indicate host '
47+
. 'or deps issues, not duplicate application execution; replay does '
48+
. 'not re-invoke activity side effects.',
49+
'history_budget_near_limit' =>
50+
'Durable history rows are the exactly-once record for this run. '
51+
. 'Continue-as-new starts a new workflow_run_id under the same '
52+
. 'workflow_instance_id rather than truncating history in place.',
53+
'condition_wait_stuck' =>
54+
'Condition waits block the run on a durable resume source. Until a '
55+
. 'signal, update, timer, or cancel fires on the named target, the '
56+
. 'wait stays open — there is no automatic abandonment.',
57+
];
58+
2059
/**
2160
* @param array<string, mixed> $detail
2261
* @return list<array<string, mixed>>
@@ -624,6 +663,7 @@ private function diagnostic(
624663
'docs_url' => $docsUrl,
625664
'evidence' => $evidence,
626665
'evidence_summary' => $evidenceSummary,
666+
'guidance' => self::GUIDANCE[$code] ?? null,
627667
];
628668
}
629669

resources/js/screens/flows/flow.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,12 @@
556556
<strong>{{ diagnostic.title }}</strong>
557557
</div>
558558
<div class="mt-1">{{ diagnostic.summary }}</div>
559+
<div
560+
class="small text-muted mt-1 font-italic"
561+
v-if="hasDetailValue(diagnostic.guidance)"
562+
>
563+
{{ diagnostic.guidance }}
564+
</div>
559565
<div class="small mt-1" v-if="diagnosticEvidenceRows(diagnostic).length">
560566
{{ diagnosticEvidenceRows(diagnostic).join(' | ') }}
561567
</div>

tests/Feature/V2DashboardWorkflowTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,25 @@ public function testShowIncludesRunDiagnosticsForCommonOperatorProblems(): void
525525
'SLA / 300s',
526526
$diagnostics->firstWhere('code', 'condition_wait_stuck')['evidence_summary'],
527527
);
528+
529+
foreach ($diagnostics as $diagnostic) {
530+
$this->assertArrayHasKey(
531+
'guidance',
532+
$diagnostic,
533+
sprintf('Run diagnostic %s must expose operator guidance aligned with the v2 execution-semantics contract.', $diagnostic['code']),
534+
);
535+
$this->assertIsString($diagnostic['guidance']);
536+
$this->assertNotSame('', $diagnostic['guidance']);
537+
}
538+
539+
$this->assertStringContainsString(
540+
'at-least-once',
541+
$diagnostics->firstWhere('code', 'activity_repeated_failure')['guidance'],
542+
);
543+
$this->assertStringContainsString(
544+
'activity_execution_id',
545+
$diagnostics->firstWhere('code', 'activity_repeated_failure')['guidance'],
546+
);
528547
}
529548

530549
public function testShowExposesDeclaredEntryMethodContractForCanonicalAndCompatibilityRuns(): void
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
namespace Waterline\Tests\Unit\Support;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Waterline\Support\RunDiagnostics;
7+
8+
/**
9+
* Pins Waterline operator-facing run diagnostics to the v2 execution-semantics
10+
* and idempotency contract frozen in the workflow package at
11+
* docs/architecture/execution-guarantees.md.
12+
*
13+
* The contract is the single reference for duplicate-execution, retry, lease
14+
* expiry, and redelivery semantics across product docs, CLI reasoning,
15+
* Waterline diagnostics, and test coverage. When the contract renames or
16+
* narrows a guarantee, the {@see RunDiagnostics::GUIDANCE} strings and this
17+
* pinning test must be updated in the same change so operator vocabulary does
18+
* not drift away from the contract silently.
19+
*
20+
* Required reading before changing this test:
21+
* - workflow package: docs/architecture/execution-guarantees.md
22+
* - workflow package: tests/Unit/V2/ExecutionGuaranteesDocumentationTest.php
23+
*/
24+
final class V2DiagnosticsExecutionContractAlignmentTest extends TestCase
25+
{
26+
/**
27+
* Every diagnostic code Waterline's RunDiagnostics can emit must carry
28+
* contract-aligned operator guidance. Adding a new diagnostic that has no
29+
* guidance string is a pinning failure; either add guidance or explicitly
30+
* map the code to null in {@see RunDiagnostics::GUIDANCE} if the
31+
* diagnostic is not a semantics teaching moment.
32+
*/
33+
public function testEveryKnownDiagnosticCodeHasGuidance(): void
34+
{
35+
$expected = [
36+
'activity_repeated_failure',
37+
'activity_heartbeat_timeout_not_effective',
38+
'activity_unbounded_retry_policy',
39+
'workflow_task_repeated_failure',
40+
'history_budget_near_limit',
41+
'condition_wait_stuck',
42+
];
43+
44+
foreach ($expected as $code) {
45+
$this->assertArrayHasKey(
46+
$code,
47+
RunDiagnostics::GUIDANCE,
48+
sprintf('RunDiagnostics::GUIDANCE is missing contract guidance for code %s.', $code),
49+
);
50+
$this->assertNotEmpty(
51+
RunDiagnostics::GUIDANCE[$code],
52+
sprintf('Contract guidance for %s must be a non-empty string.', $code),
53+
);
54+
}
55+
}
56+
57+
/**
58+
* Duplicate execution is a first-class distributed reality in the v2
59+
* contract, not a bug condition. Activity-retry guidance must describe
60+
* retries as at-least-once and direct the operator to the
61+
* activity_execution_id idempotency surface, which is the default
62+
* framework-provided idempotency key that stays stable across retries.
63+
*/
64+
public function testActivityRetryGuidanceCitesAtLeastOnceAndExecutionIdSurface(): void
65+
{
66+
$guidance = RunDiagnostics::GUIDANCE['activity_repeated_failure'];
67+
68+
$this->assertStringContainsString('at-least-once', $guidance);
69+
$this->assertStringContainsString('activity_execution_id', $guidance);
70+
$this->assertStringContainsString('idempotency', $guidance);
71+
}
72+
73+
/**
74+
* The heartbeat-timeout diagnostic must explain that heartbeats renew the
75+
* activity attempt lease, and that a start_to_close timeout that fires
76+
* first prevents lease expiry from triggering redelivery. The contract
77+
* language for this surface is "lease" plus the concrete timeout fields.
78+
*/
79+
public function testHeartbeatTimeoutGuidanceCitesLeaseAndTimeoutFields(): void
80+
{
81+
$guidance = RunDiagnostics::GUIDANCE['activity_heartbeat_timeout_not_effective'];
82+
83+
$this->assertStringContainsString('Heartbeat', $guidance);
84+
$this->assertStringContainsString('lease', $guidance);
85+
$this->assertStringContainsString('start_to_close_timeout', $guidance);
86+
$this->assertStringContainsString('heartbeat_timeout', $guidance);
87+
}
88+
89+
/**
90+
* Unbounded retry policies keep producing new attempts for the same
91+
* activity_execution_id. Contract guidance names at-least-once and the
92+
* execution-id surface so operators can reason about the dedupe key the
93+
* retries share.
94+
*/
95+
public function testUnboundedRetryGuidanceCitesAtLeastOnceAndExecutionIdSurface(): void
96+
{
97+
$guidance = RunDiagnostics::GUIDANCE['activity_unbounded_retry_policy'];
98+
99+
$this->assertStringContainsString('at-least-once', $guidance);
100+
$this->assertStringContainsString('activity_execution_id', $guidance);
101+
$this->assertStringContainsString('max_attempts', $guidance);
102+
}
103+
104+
/**
105+
* Workflow-task failures are never duplicate application execution — the
106+
* workflow body is replayed deterministically. Contract guidance must
107+
* separate workflow-task replay from activity at-least-once so operators
108+
* stop reading repeated workflow-task failures as duplicate side effects.
109+
*/
110+
public function testWorkflowTaskGuidanceCitesDeterministicReplay(): void
111+
{
112+
$guidance = RunDiagnostics::GUIDANCE['workflow_task_repeated_failure'];
113+
114+
$this->assertStringContainsString('replay', $guidance);
115+
$this->assertStringContainsString('deterministic', $guidance);
116+
$this->assertStringContainsString('side effect', $guidance);
117+
}
118+
119+
/**
120+
* The history budget diagnostic must point operators at continue-as-new
121+
* as a new-run primitive, not at history truncation. The contract is
122+
* explicit that the durable history rows are exactly-once at the durable
123+
* state layer.
124+
*/
125+
public function testHistoryBudgetGuidanceCitesContinueAsNewAndDurableHistory(): void
126+
{
127+
$guidance = RunDiagnostics::GUIDANCE['history_budget_near_limit'];
128+
129+
$this->assertStringContainsString('Continue-as-new', $guidance);
130+
$this->assertStringContainsString('workflow_run_id', $guidance);
131+
$this->assertStringContainsString('workflow_instance_id', $guidance);
132+
$this->assertStringContainsString('exactly-once', $guidance);
133+
}
134+
135+
/**
136+
* Condition waits block the run on a durable resume source. Contract
137+
* guidance names the resume sources an operator should look for so the
138+
* "stuck" state reads as a missing signal/update/timer rather than a
139+
* framework bug.
140+
*/
141+
public function testConditionWaitGuidanceCitesResumeSources(): void
142+
{
143+
$guidance = RunDiagnostics::GUIDANCE['condition_wait_stuck'];
144+
145+
$this->assertStringContainsString('durable', $guidance);
146+
$this->assertStringContainsString('signal', $guidance);
147+
$this->assertStringContainsString('update', $guidance);
148+
}
149+
150+
/**
151+
* Guidance strings are reviewed as part of the contract. Every guidance
152+
* entry must stay short (single paragraph) so it fits next to the
153+
* diagnostic in operator tooling.
154+
*/
155+
public function testGuidanceStringsAreConciseSingleParagraphs(): void
156+
{
157+
foreach (RunDiagnostics::GUIDANCE as $code => $guidance) {
158+
$this->assertIsString($guidance, sprintf('Guidance for %s must be a string.', $code));
159+
$this->assertLessThanOrEqual(
160+
400,
161+
strlen($guidance),
162+
sprintf('Guidance for %s is longer than 400 characters; keep it concise.', $code),
163+
);
164+
$this->assertStringNotContainsString(
165+
"\n\n",
166+
$guidance,
167+
sprintf('Guidance for %s must be a single paragraph with no blank lines.', $code),
168+
);
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)