From 6def9fde721904fafb490579fd838cc0a2c9315e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:07:15 -0600 Subject: [PATCH 1/2] Adopt PHP SDK 3.9.2 so a wrong signal keeps the approval deadline PHP SDK 3.9.2 reopens a falsely woken condition wait with the time left since it first opened (sdk-php#269). Before, any signal that left the predicate false, such as a decision for another proposal, restarted the service-mode conversation's '5 minutes' approval timer. Raise the root, microservice and playground constraints to ^3.9.2 and regenerate their locks. The tuple and README follow. The service-mode test's simulated server now stamps events and, as native Server does, records the old wait satisfied and opens the new one when a signal makes the worker send the wait again. A new test sends two wrong decisions at +120 s and +270 s and checks the reopens carry 180 and 30 seconds, then that the proposal expires at five minutes. On 3.9.1 the first reopen carries 300. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- composer.json | 2 +- composer.lock | 16 +++--- microservice/composer.json | 2 +- microservice/composer.lock | 16 +++--- playground/php-runtime/composer.json | 2 +- playground/php-runtime/composer.lock | 16 +++--- polyglot/qualified-artifact-tuple.json | 2 +- .../ServiceMode/ConversationWorkflowTest.php | 55 ++++++++++++++++++- 9 files changed, 81 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 15974f76..2a2c6870 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ all three runtimes and the artifact versions used for the run. The qualified tuple in [`polyglot/qualified-artifact-tuple.json`](polyglot/qualified-artifact-tuple.json) pins every published artifact the journeys use: native Durable Workflow Server -3.0.4 (by image digest), Workflow 2.20.1, Waterline 2.13.2, PHP SDK 3.9.1, +3.0.4 (by image digest), Workflow 2.20.1, Waterline 2.13.2, PHP SDK 3.9.2, Python SDK 2.13.0, Rust SDK 4.10.3 and CLI 2.3.3. `artifacts.workflow` selects the embedded application's Composer package; the Composer lock must match it. Native Server is one process that creates its schema and `default` namespace diff --git a/composer.json b/composer.json index 5318585d..b9ecd568 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.4", "durable-workflow/ai": "dev-main", - "durable-workflow/sdk": "^3.9.1", + "durable-workflow/sdk": "^3.9.2", "durable-workflow/waterline": "^2.0", "durable-workflow/workflow": "^2.20.1", "laravel/ai": "^0.6.0", diff --git a/composer.lock b/composer.lock index d04cc0ea..875ce518 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f0814b9eafcdfbcc1945c50df8098196", + "content-hash": "d32497fbd14dd8bdbf953215f976c68c", "packages": [ { "name": "apache/avro", @@ -720,16 +720,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.1", + "version": "3.9.2", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e" + "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e4d77aade4ddc02eb0ba40f827709b8b4817725e", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e5d6f840a1f5f20ba9e90867b021bae29119268f", + "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f", "shasum": "" }, "require": { @@ -781,7 +781,7 @@ "laravel": "^10.50.4|^11.56.1|^12.69.0|^13.30.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "3.9.1", + "product-train": "3.9.2", "payload-codecs": [ "avro" ], @@ -851,8 +851,8 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-11-04T02:37:04+00:00", - "published-time": "2026-11-04T02:37:27+00:00" + "time": "2026-11-04T04:02:05+00:00", + "published-time": "2026-11-04T04:02:22+00:00" }, { "name": "durable-workflow/waterline", diff --git a/microservice/composer.json b/microservice/composer.json index 41be7b9d..efe292db 100644 --- a/microservice/composer.json +++ b/microservice/composer.json @@ -10,7 +10,7 @@ "license": "MIT", "require": { "php": "^8.4", - "durable-workflow/sdk": "^3.9.1", + "durable-workflow/sdk": "^3.9.2", "durable-workflow/workflow": "^2.20.1", "durable-workflow/waterline": "^2.0", "laravel/framework": "^12.69", diff --git a/microservice/composer.lock b/microservice/composer.lock index 35c528ac..6d9c58aa 100644 --- a/microservice/composer.lock +++ b/microservice/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8f09bfe6ba2d5b90cfbdcebbe644d23", + "content-hash": "6c96c1b034f7318ba9a295abfb923fd4", "packages": [ { "name": "apache/avro", @@ -505,16 +505,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.1", + "version": "3.9.2", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e" + "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e4d77aade4ddc02eb0ba40f827709b8b4817725e", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e5d6f840a1f5f20ba9e90867b021bae29119268f", + "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f", "shasum": "" }, "require": { @@ -566,7 +566,7 @@ "laravel": "^10.50.4|^11.56.1|^12.69.0|^13.30.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "3.9.1", + "product-train": "3.9.2", "payload-codecs": [ "avro" ], @@ -636,8 +636,8 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-11-04T02:37:04+00:00", - "published-time": "2026-11-04T02:37:27+00:00" + "time": "2026-11-04T04:02:05+00:00", + "published-time": "2026-11-04T04:02:22+00:00" }, { "name": "durable-workflow/waterline", diff --git a/playground/php-runtime/composer.json b/playground/php-runtime/composer.json index c2a61722..87cc3d8b 100644 --- a/playground/php-runtime/composer.json +++ b/playground/php-runtime/composer.json @@ -6,7 +6,7 @@ "license": "MIT", "require": { "php": "^8.4", - "durable-workflow/sdk": "^3.9.1" + "durable-workflow/sdk": "^3.9.2" }, "config": { "allow-plugins": false, diff --git a/playground/php-runtime/composer.lock b/playground/php-runtime/composer.lock index ed0497f3..41115c01 100644 --- a/playground/php-runtime/composer.lock +++ b/playground/php-runtime/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0bbb9bdb7acc5fd5f16bf5bae61c8e22", + "content-hash": "84a88bb4d3d4741b4b2ab0aca1555b86", "packages": [ { "name": "apache/avro", @@ -145,16 +145,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.1", + "version": "3.9.2", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e" + "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e4d77aade4ddc02eb0ba40f827709b8b4817725e", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e5d6f840a1f5f20ba9e90867b021bae29119268f", + "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f", "shasum": "" }, "require": { @@ -206,7 +206,7 @@ "laravel": "^10.50.4|^11.56.1|^12.69.0|^13.30.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "3.9.1", + "product-train": "3.9.2", "payload-codecs": [ "avro" ], @@ -276,8 +276,8 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-11-04T02:37:04+00:00", - "published-time": "2026-11-04T02:37:27+00:00" + "time": "2026-11-04T04:02:05+00:00", + "published-time": "2026-11-04T04:02:22+00:00" }, { "name": "guzzlehttp/guzzle", diff --git a/polyglot/qualified-artifact-tuple.json b/polyglot/qualified-artifact-tuple.json index 00786bd3..0fef93eb 100644 --- a/polyglot/qualified-artifact-tuple.json +++ b/polyglot/qualified-artifact-tuple.json @@ -5,7 +5,7 @@ "serverImage": "durableworkflow/server:3.0.4@sha256:013010476b78f37038ac09d9498a95cdbf5ae7896111c321b2d4a2aac2f81f6b", "artifacts": { "cli": "2.3.3", - "sdk-php": "3.9.1", + "sdk-php": "3.9.2", "sdk-python": "2.13.0", "sdk-rust": "4.10.3", "server": "3.0.4", diff --git a/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php b/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php index 1558b13e..820d2fc5 100644 --- a/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php +++ b/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php @@ -9,6 +9,7 @@ use App\Workflows\ServiceMode\Conversation\ConversationActivities; use App\Workflows\ServiceMode\Conversation\ConversationWorkflow; use App\Workflows\ServiceMode\WelcomeWorkflow; +use DateTimeImmutable; use DurableWorkflow\Bridge\Laravel\WorkerFactory; use DurableWorkflow\Client; use DurableWorkflow\Codec\AvroPayloadCodec; @@ -50,6 +51,8 @@ final class ConversationWorkflowTest extends TestCase private int $nextSequence = 1; + private DateTimeImmutable $clock; + /** @var array{type: string, value: mixed}|null */ private ?array $closed = null; @@ -163,6 +166,33 @@ public function test_undecided_proposal_changes_nothing_after_its_timeout(): voi $this->assertSame(0, ConversationRecord::query()->where('kind', 'action')->count()); } + public function test_a_wrong_decision_keeps_the_five_minute_approval_deadline(): void + { + $this->start([]); + $this->signal('message', 'Please refund my order'); + $this->assertSame(300, $this->lastEvent('ConditionWaitOpened')['timeout_seconds']); + + // Any signal wakes the open wait. The predicate is still false, so the + // worker reopens the wait with the time left, not another five minutes. + $this->advance(120); + $this->signal('decision', 'not-this-proposal', true, 'Stale'); + $reopened = $this->lastEvent('ConditionWaitOpened'); + $this->assertSame('decision-1', $reopened['condition_key']); + $this->assertSame(180, $reopened['timeout_seconds']); + + $this->advance(150); + $this->signal('decision', 'still-not-this-proposal', true, 'Stale'); + $this->assertSame(30, $this->lastEvent('ConditionWaitOpened')['timeout_seconds']); + + $this->advance(30); + $this->timeOutOpenWait(); + + $this->assertSame( + ['Turn 1: I can refund the most recent order once a reviewer approves. No decision arrived in time, so nothing was changed.'], + $this->replies(), + ); + } + public function test_conversation_ends_after_the_inactivity_timeout(): void { $this->start([]); @@ -227,6 +257,7 @@ private function start(array $input): void { $this->history = []; $this->nextSequence = 1; + $this->clock = new DateTimeImmutable('2026-11-04T02:36:05.295+00:00'); $this->closed = null; $this->input = $input; $this->process(); @@ -235,7 +266,12 @@ private function start(array $input): void private function signal(string $name, mixed ...$arguments): void { $this->record('SignalReceived', ['signal_name' => $name, 'value' => $this->codec->envelope($arguments)]); - $this->process(); + $this->process(woken: true); + } + + private function advance(int $seconds): void + { + $this->clock = $this->clock->modify("+{$seconds} seconds"); } private function timeOutOpenWait(): void @@ -248,8 +284,12 @@ private function timeOutOpenWait(): void /** * Runs workflow tasks until the workflow waits or closes. + * + * After a signal, a worker whose predicate is still false sends the open + * wait again. Native Server then records the old wait satisfied and opens + * a new one with the timeout the worker sent. */ - private function process(): void + private function process(bool $woken = false): void { for ($task = 0; $task < 50 && $this->closed === null; $task++) { $commands = $this->harness->runWorkflow( @@ -266,6 +306,11 @@ private function process(): void && $commands[0]['condition_key'] === $wait['condition_key']; if ($stillWaiting) { + if ($woken) { + $this->record('ConditionWaitSatisfied', ['sequence' => $wait['sequence'], 'condition_wait_id' => $wait['condition_wait_id']]); + $this->apply($commands[0]); + } + return; } @@ -333,7 +378,11 @@ private function apply(array $command): void */ private function record(string $type, array $payload): void { - $this->history[] = ['event_type' => $type, 'payload' => $payload]; + $this->history[] = [ + 'event_type' => $type, + 'timestamp' => $this->clock->format('Y-m-d\\TH:i:s.uP'), + 'payload' => $payload, + ]; } /** From b63daa676434688024e6246acf1afac9814ed974 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:33:35 -0600 Subject: [PATCH 2/2] Move to PHP SDK 3.9.4 and test an orphaned duplicate wait PHP SDK 3.9.2 failed replay once an orphaned duplicate condition wait's timer fired (sdk-php#283). Native Server 3.0.x leaves such an orphan when a second queued workflow task sends an already-open wait again. 3.9.3 fixed it and 3.9.4 is the current patch release. Raise the constraints to ^3.9.4 with composer update durable-workflow/sdk only. The new test records a duplicate open, a wrong decision, then the orphan's timer firing. On 3.9.2 replay throws NonDeterministicWorkflow. On 3.9.4 the run keeps waiting and the proposal expires with no action. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- composer.json | 2 +- composer.lock | 16 +++--- microservice/composer.json | 2 +- microservice/composer.lock | 16 +++--- playground/php-runtime/composer.json | 2 +- playground/php-runtime/composer.lock | 16 +++--- polyglot/qualified-artifact-tuple.json | 2 +- .../ServiceMode/ConversationWorkflowTest.php | 55 +++++++++++++++++++ 9 files changed, 84 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index af75677e..87f35db2 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ all three runtimes and the artifact versions used for the run. The qualified tuple in [`polyglot/qualified-artifact-tuple.json`](polyglot/qualified-artifact-tuple.json) pins every published artifact the journeys use: native Durable Workflow Server -3.0.5 (by image digest), Workflow 2.20.1, Waterline 2.13.2, PHP SDK 3.9.2, +3.0.5 (by image digest), Workflow 2.20.1, Waterline 2.13.2, PHP SDK 3.9.4, Python SDK 2.13.0, Rust SDK 4.10.3 and CLI 2.3.3. `artifacts.workflow` selects the embedded application's Composer package; the Composer lock must match it. Native Server is one process that creates its schema and `default` namespace diff --git a/composer.json b/composer.json index b9ecd568..afc399b0 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.4", "durable-workflow/ai": "dev-main", - "durable-workflow/sdk": "^3.9.2", + "durable-workflow/sdk": "^3.9.4", "durable-workflow/waterline": "^2.0", "durable-workflow/workflow": "^2.20.1", "laravel/ai": "^0.6.0", diff --git a/composer.lock b/composer.lock index 875ce518..c4456a5d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d32497fbd14dd8bdbf953215f976c68c", + "content-hash": "a9b8086ae4083a8a66acd5685d4d44f9", "packages": [ { "name": "apache/avro", @@ -720,16 +720,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.2", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f" + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e5d6f840a1f5f20ba9e90867b021bae29119268f", - "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/11085f0c624674a4b70d3a6fb3e83eaff3869fcb", + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb", "shasum": "" }, "require": { @@ -781,7 +781,7 @@ "laravel": "^10.50.4|^11.56.1|^12.69.0|^13.30.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "3.9.2", + "product-train": "3.9.4", "payload-codecs": [ "avro" ], @@ -851,8 +851,8 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-11-04T04:02:05+00:00", - "published-time": "2026-11-04T04:02:22+00:00" + "time": "2026-11-04T05:28:03+00:00", + "published-time": "2026-11-04T05:28:17+00:00" }, { "name": "durable-workflow/waterline", diff --git a/microservice/composer.json b/microservice/composer.json index efe292db..31a06b0d 100644 --- a/microservice/composer.json +++ b/microservice/composer.json @@ -10,7 +10,7 @@ "license": "MIT", "require": { "php": "^8.4", - "durable-workflow/sdk": "^3.9.2", + "durable-workflow/sdk": "^3.9.4", "durable-workflow/workflow": "^2.20.1", "durable-workflow/waterline": "^2.0", "laravel/framework": "^12.69", diff --git a/microservice/composer.lock b/microservice/composer.lock index 6d9c58aa..690b59ce 100644 --- a/microservice/composer.lock +++ b/microservice/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6c96c1b034f7318ba9a295abfb923fd4", + "content-hash": "640a15ace917897880755cb8e0293ba2", "packages": [ { "name": "apache/avro", @@ -505,16 +505,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.2", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f" + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e5d6f840a1f5f20ba9e90867b021bae29119268f", - "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/11085f0c624674a4b70d3a6fb3e83eaff3869fcb", + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb", "shasum": "" }, "require": { @@ -566,7 +566,7 @@ "laravel": "^10.50.4|^11.56.1|^12.69.0|^13.30.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "3.9.2", + "product-train": "3.9.4", "payload-codecs": [ "avro" ], @@ -636,8 +636,8 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-11-04T04:02:05+00:00", - "published-time": "2026-11-04T04:02:22+00:00" + "time": "2026-11-04T05:28:03+00:00", + "published-time": "2026-11-04T05:28:17+00:00" }, { "name": "durable-workflow/waterline", diff --git a/playground/php-runtime/composer.json b/playground/php-runtime/composer.json index 87cc3d8b..92c730cc 100644 --- a/playground/php-runtime/composer.json +++ b/playground/php-runtime/composer.json @@ -6,7 +6,7 @@ "license": "MIT", "require": { "php": "^8.4", - "durable-workflow/sdk": "^3.9.2" + "durable-workflow/sdk": "^3.9.4" }, "config": { "allow-plugins": false, diff --git a/playground/php-runtime/composer.lock b/playground/php-runtime/composer.lock index 41115c01..cc787232 100644 --- a/playground/php-runtime/composer.lock +++ b/playground/php-runtime/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "84a88bb4d3d4741b4b2ab0aca1555b86", + "content-hash": "53d58b4b6598a1f6311305b40312b9d9", "packages": [ { "name": "apache/avro", @@ -145,16 +145,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.2", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f" + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/e5d6f840a1f5f20ba9e90867b021bae29119268f", - "reference": "e5d6f840a1f5f20ba9e90867b021bae29119268f", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/11085f0c624674a4b70d3a6fb3e83eaff3869fcb", + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb", "shasum": "" }, "require": { @@ -206,7 +206,7 @@ "laravel": "^10.50.4|^11.56.1|^12.69.0|^13.30.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "3.9.2", + "product-train": "3.9.4", "payload-codecs": [ "avro" ], @@ -276,8 +276,8 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-11-04T04:02:05+00:00", - "published-time": "2026-11-04T04:02:22+00:00" + "time": "2026-11-04T05:28:03+00:00", + "published-time": "2026-11-04T05:28:17+00:00" }, { "name": "guzzlehttp/guzzle", diff --git a/polyglot/qualified-artifact-tuple.json b/polyglot/qualified-artifact-tuple.json index 3d1c2f3b..4929fa92 100644 --- a/polyglot/qualified-artifact-tuple.json +++ b/polyglot/qualified-artifact-tuple.json @@ -5,7 +5,7 @@ "serverImage": "durableworkflow/server:3.0.5@sha256:3cb6e5bf8ec766ac2abd933369b40a77af62794b6d7b921ce21d3fb263e88b72", "artifacts": { "cli": "2.3.3", - "sdk-php": "3.9.2", + "sdk-php": "3.9.4", "sdk-python": "2.13.0", "sdk-rust": "4.10.3", "server": "3.0.5", diff --git a/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php b/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php index 820d2fc5..7985e5f2 100644 --- a/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php +++ b/tests/Feature/Workflows/ServiceMode/ConversationWorkflowTest.php @@ -193,6 +193,44 @@ public function test_a_wrong_decision_keeps_the_five_minute_approval_deadline(): ); } + public function test_an_orphaned_duplicate_wait_timing_out_does_not_break_replay(): void + { + $this->start([]); + $this->signal('message', 'Please refund my order'); + $orphan = $this->lastEvent('ConditionWaitOpened'); + + // A second queued workflow task replays to the open wait and sends it + // again. Native Server 3.0.x opens another wait and leaves the first + // one, with its timer, unresolved (sdk-php#283). + $this->duplicateOpen(); + $this->assertNotSame($orphan['sequence'], $this->lastEvent('ConditionWaitOpened')['sequence']); + + $this->advance(120); + $this->signal('decision', 'not-this-proposal', true, 'Stale'); + $this->assertSame(180, $this->lastEvent('ConditionWaitOpened')['timeout_seconds']); + + // The orphan's timer fires at its own five minutes. Replay must keep + // treating it as the same logical wait instead of failing. + $this->advance(180); + $this->record('TimerFired', [ + 'sequence' => $orphan['sequence'], + 'timer_kind' => 'condition_timeout', + 'condition_key' => $orphan['condition_key'], + 'condition_wait_id' => $orphan['condition_wait_id'], + 'condition_definition_fingerprint' => $orphan['condition_definition_fingerprint'], + 'delay_seconds' => 300, + ]); + $this->process(); + $this->assertNull($this->closed); + + $this->timeOutOpenWait(); + + $this->assertSame( + ['Turn 1: I can refund the most recent order once a reviewer approves. No decision arrived in time, so nothing was changed.'], + $this->replies(), + ); + } + public function test_conversation_ends_after_the_inactivity_timeout(): void { $this->start([]); @@ -269,6 +307,23 @@ private function signal(string $name, mixed ...$arguments): void $this->process(woken: true); } + /** + * Replays the history again with nothing new, as a second queued task + * would, and records the wait it sends as a new open. + */ + private function duplicateOpen(): void + { + $commands = $this->harness->runWorkflow( + ConversationWorkflow::TYPE, + $this->input, + $this->history, + ['workflow_id' => self::WORKFLOW_ID, 'run_id' => self::RUN_ID], + )->commands; + + $this->assertSame('open_condition_wait', $commands[0]['type'] ?? null); + $this->apply($commands[0]); + } + private function advance(int $seconds): void { $this->clock = $this->clock->modify("+{$seconds} seconds");