diff --git a/README.md b/README.md index aa277f24..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.1, +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 5318585d..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.1", + "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 d04cc0ea..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": "f0814b9eafcdfbcc1945c50df8098196", + "content-hash": "a9b8086ae4083a8a66acd5685d4d44f9", "packages": [ { "name": "apache/avro", @@ -720,16 +720,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.1", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e" + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb" }, "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/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.1", + "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-04T02:37:04+00:00", - "published-time": "2026-11-04T02:37:27+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 41be7b9d..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.1", + "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 35c528ac..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": "e8f09bfe6ba2d5b90cfbdcebbe644d23", + "content-hash": "640a15ace917897880755cb8e0293ba2", "packages": [ { "name": "apache/avro", @@ -505,16 +505,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.1", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e" + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb" }, "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/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.1", + "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-04T02:37:04+00:00", - "published-time": "2026-11-04T02:37:27+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 c2a61722..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.1" + "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 ed0497f3..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": "0bbb9bdb7acc5fd5f16bf5bae61c8e22", + "content-hash": "53d58b4b6598a1f6311305b40312b9d9", "packages": [ { "name": "apache/avro", @@ -145,16 +145,16 @@ }, { "name": "durable-workflow/sdk", - "version": "3.9.1", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "e4d77aade4ddc02eb0ba40f827709b8b4817725e" + "reference": "11085f0c624674a4b70d3a6fb3e83eaff3869fcb" }, "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/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.1", + "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-04T02:37:04+00:00", - "published-time": "2026-11-04T02:37:27+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 06175dab..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.1", + "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 1558b13e..7985e5f2 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,71 @@ 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_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([]); @@ -227,6 +295,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 +304,29 @@ 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); + } + + /** + * 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"); } private function timeOutOpenWait(): void @@ -248,8 +339,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 +361,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 +433,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, + ]; } /**