ttasks 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {ttasks-0.2.0 → ttasks-0.2.1}/.github/workflows/docs.yml +3 -0
  2. {ttasks-0.2.0 → ttasks-0.2.1}/.github/workflows/publish.yml +3 -0
  3. {ttasks-0.2.0 → ttasks-0.2.1}/PKG-INFO +3 -5
  4. {ttasks-0.2.0 → ttasks-0.2.1}/README.md +2 -4
  5. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_executor.py +7 -2
  6. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_graph.py +25 -6
  7. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_sqlite.py +21 -2
  8. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_store.py +6 -2
  9. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_task.py +4 -1
  10. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_version.py +2 -2
  11. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_executor.py +45 -0
  12. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_sqlite_store.py +10 -0
  13. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_store.py +8 -0
  14. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_task.py +23 -0
  15. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_workflow.py +78 -0
  16. {ttasks-0.2.0 → ttasks-0.2.1}/.gitignore +0 -0
  17. {ttasks-0.2.0 → ttasks-0.2.1}/.python-version +0 -0
  18. {ttasks-0.2.0 → ttasks-0.2.1}/.vscode/launch.json +0 -0
  19. {ttasks-0.2.0 → ttasks-0.2.1}/.vscode/settings.json +0 -0
  20. {ttasks-0.2.0 → ttasks-0.2.1}/main.py +0 -0
  21. {ttasks-0.2.0 → ttasks-0.2.1}/pyproject.toml +0 -0
  22. {ttasks-0.2.0 → ttasks-0.2.1}/scripts/preflight.py +0 -0
  23. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/__init__.py +0 -0
  24. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_events.py +0 -0
  25. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_exceptions.py +0 -0
  26. {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/py.typed +0 -0
  27. {ttasks-0.2.0 → ttasks-0.2.1}/tests/conftest.py +0 -0
  28. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_e2e.py +0 -0
  29. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_events.py +0 -0
  30. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_public_api.py +0 -0
  31. {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_task_factories.py +0 -0
  32. {ttasks-0.2.0 → ttasks-0.2.1}/uv.lock +0 -0
@@ -5,6 +5,9 @@ on:
5
5
  branches: [master]
6
6
  workflow_dispatch:
7
7
 
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
10
+
8
11
  permissions:
9
12
  contents: read
10
13
  pages: write
@@ -5,6 +5,9 @@ on:
5
5
  tags:
6
6
  - "v*"
7
7
 
8
+ env:
9
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
10
+
8
11
  jobs:
9
12
  build:
10
13
  runs-on: ubuntu-latest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ttasks
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: github-copilot-sdk>=0.1.0
@@ -133,8 +133,7 @@ assert task in store.tasks # id-membership shortcut
133
133
  `InMemoryStore`. A single SQLite file holds both tasks and graphs.
134
134
 
135
135
  ```python
136
- from ttasks import Task, TaskGraph, TaskExecutor
137
- from ttasks.storage.sqlite import SQLiteStore
136
+ from ttasks import Task, TaskGraph, TaskExecutor, SQLiteStore
138
137
 
139
138
  store = SQLiteStore("ttasks.db")
140
139
 
@@ -179,8 +178,7 @@ Persistence failures are isolated from execution: they are recorded on
179
178
  out of `execute()` and does not transition the task to `FAILED`.
180
179
 
181
180
  ```python
182
- from ttasks import TaskExecutor
183
- from ttasks.storage.sqlite import SQLiteStore
181
+ from ttasks import TaskExecutor, SQLiteStore
184
182
 
185
183
  store = SQLiteStore("ttasks.db")
186
184
  executor = TaskExecutor(store=store)
@@ -125,8 +125,7 @@ assert task in store.tasks # id-membership shortcut
125
125
  `InMemoryStore`. A single SQLite file holds both tasks and graphs.
126
126
 
127
127
  ```python
128
- from ttasks import Task, TaskGraph, TaskExecutor
129
- from ttasks.storage.sqlite import SQLiteStore
128
+ from ttasks import Task, TaskGraph, TaskExecutor, SQLiteStore
130
129
 
131
130
  store = SQLiteStore("ttasks.db")
132
131
 
@@ -171,8 +170,7 @@ Persistence failures are isolated from execution: they are recorded on
171
170
  out of `execute()` and does not transition the task to `FAILED`.
172
171
 
173
172
  ```python
174
- from ttasks import TaskExecutor
175
- from ttasks.storage.sqlite import SQLiteStore
173
+ from ttasks import TaskExecutor, SQLiteStore
176
174
 
177
175
  store = SQLiteStore("ttasks.db")
178
176
  executor = TaskExecutor(store=store)
@@ -167,8 +167,8 @@ class TaskExecutor:
167
167
  emits the BLOCKED event so observers and the store see the outcome.
168
168
  """
169
169
  previous_status = task.status
170
- task._set_blocked_by(parent_id)
171
170
  task.transition_to(TaskStatus.BLOCKED)
171
+ task._set_blocked_by(parent_id)
172
172
  self._emit(task, TaskEventType.BLOCKED, previous_status)
173
173
 
174
174
  def _terminalize(
@@ -333,6 +333,9 @@ class TaskExecutor:
333
333
  handler = self._handlers.get(task.type)
334
334
  if handler is None:
335
335
  message = f"No handler registered for task type {task.type.value!r}"
336
+ previous_status = task.status
337
+ if not task.can_transition_to(TaskStatus.FAILED):
338
+ task.transition_to(TaskStatus.RUNNING)
336
339
  finished_at = datetime.now()
337
340
  failed_result = TaskResult(
338
341
  task_id=task.id,
@@ -347,7 +350,7 @@ class TaskExecutor:
347
350
  task,
348
351
  failed_result,
349
352
  TaskStatus.FAILED,
350
- previous=TaskStatus.PENDING,
353
+ previous=previous_status,
351
354
  event_type=TaskEventType.FAILED,
352
355
  error=message,
353
356
  )
@@ -471,6 +474,8 @@ class TaskExecutor:
471
474
  args,
472
475
  shell=shell,
473
476
  text=True,
477
+ encoding="utf-8",
478
+ errors="replace",
474
479
  stdout=subprocess.PIPE,
475
480
  stderr=subprocess.PIPE,
476
481
  start_new_session=True,
@@ -70,7 +70,11 @@ class TaskGraph:
70
70
  raise ValueError("required=False is only valid with finally_=True")
71
71
 
72
72
  self._tasks[task.id] = task
73
- self._deps[task.id] = [d.id for d in after]
73
+ deps: list[str] = []
74
+ for dep in after:
75
+ if dep.id not in deps:
76
+ deps.append(dep.id)
77
+ self._deps[task.id] = deps
74
78
  if finally_:
75
79
  self._finally.add(task.id)
76
80
  if required:
@@ -170,6 +174,7 @@ class TaskGraph:
170
174
  """True iff every required task succeeded without run errors."""
171
175
  return all(
172
176
  self._tasks[tid].status == TaskStatus.SUCCEEDED
177
+ and tid not in self._errors
173
178
  for tid in self._deps
174
179
  if tid not in self._optional
175
180
  )
@@ -288,11 +293,23 @@ class TaskGraph:
288
293
  """Return whether tid is already done or succeeded in this run."""
289
294
  return self._tasks[tid].status == TaskStatus.SUCCEEDED
290
295
 
296
+ def retryable_this_run(tid: str) -> bool:
297
+ """Return whether a bad-status task can still recover this run."""
298
+ task = self._tasks[tid]
299
+ return (
300
+ tid not in futures
301
+ and task.can_transition_to(TaskStatus.RUNNING)
302
+ and (
303
+ task.status != TaskStatus.BLOCKED
304
+ or tid in entering_blocked
305
+ )
306
+ )
307
+
291
308
  def inactive(tid: str) -> bool:
292
309
  """Return whether tid can no longer change in this run."""
293
310
  task = self._tasks[tid]
294
311
  return (
295
- task.is_terminal
312
+ (task.is_terminal and not retryable_this_run(tid))
296
313
  or tid in self._errors
297
314
  or (tid in futures and futures[tid].done())
298
315
  )
@@ -307,12 +324,14 @@ class TaskGraph:
307
324
  """Return the first dep (in declaration order) blocking ``tid``.
308
325
 
309
326
  A parent "blocks" when its status is FAILED, CANCELLED, or
310
- BLOCKED. Returns ``None`` if every parent is still
311
- recoverable. Pre-start handler errors terminalize the parent
312
- to FAILED before raising, so status alone is authoritative.
327
+ BLOCKED and it cannot still be retried during this run.
328
+ Returns ``None`` if every bad parent is still recoverable.
329
+ Pre-start handler errors terminalize the parent to FAILED
330
+ before raising, so status plus retry eligibility is
331
+ authoritative.
313
332
  """
314
333
  for d in self._deps[tid]:
315
- if self._tasks[d].status.is_bad:
334
+ if self._tasks[d].status.is_bad and not retryable_this_run(d):
316
335
  return d
317
336
  return None
318
337
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sqlite3
6
+ import uuid
6
7
  import warnings
7
8
  from collections.abc import Iterator, MutableMapping
8
9
  from datetime import datetime
@@ -43,7 +44,18 @@ class _Connection:
43
44
  ) -> None:
44
45
  """Open or create the SQLite database at ``path`` and init the schema."""
45
46
  self.path = Path(path)
46
- if self.path.parent != Path(""):
47
+ self._memory_uri: str | None = None
48
+ self._anchor_connection: sqlite3.Connection | None = None
49
+ if str(path) == ":memory:":
50
+ self._memory_uri = (
51
+ f"file:ttasks-{uuid.uuid4().hex}?mode=memory&cache=shared"
52
+ )
53
+ self._anchor_connection = sqlite3.connect(
54
+ self._memory_uri,
55
+ uri=True,
56
+ timeout=_CONNECT_TIMEOUT_SECONDS,
57
+ )
58
+ elif self.path.parent != Path(""):
47
59
  self.path.parent.mkdir(parents=True, exist_ok=True)
48
60
  self._schema_lock = RLock()
49
61
  self._allow_destructive = allow_destructive_migration
@@ -51,7 +63,14 @@ class _Connection:
51
63
 
52
64
  def connect(self) -> sqlite3.Connection:
53
65
  """Return a fresh SQLite connection configured for the store."""
54
- connection = sqlite3.connect(self.path, timeout=_CONNECT_TIMEOUT_SECONDS)
66
+ if self._memory_uri is None:
67
+ connection = sqlite3.connect(self.path, timeout=_CONNECT_TIMEOUT_SECONDS)
68
+ else:
69
+ connection = sqlite3.connect(
70
+ self._memory_uri,
71
+ uri=True,
72
+ timeout=_CONNECT_TIMEOUT_SECONDS,
73
+ )
55
74
  connection.row_factory = sqlite3.Row
56
75
  connection.execute("PRAGMA foreign_keys = ON")
57
76
  return connection
@@ -103,7 +103,9 @@ class InMemoryTaskCollection(MutableMapping[str, Task]):
103
103
  def __contains__(self, key: object) -> bool:
104
104
  """Return whether ``key`` (task or task id) is present."""
105
105
  if isinstance(key, Task):
106
- return key.id in self._tasks
106
+ key = key.id
107
+ if not isinstance(key, str):
108
+ return False
107
109
  return key in self._tasks
108
110
 
109
111
  def __repr__(self) -> str:
@@ -153,7 +155,9 @@ class InMemoryGraphCollection(MutableMapping[str, "TaskGraph"]):
153
155
  def __contains__(self, key: object) -> bool:
154
156
  """Return whether ``key`` (graph or graph id) is present."""
155
157
  if isinstance(key, TaskGraph):
156
- return key.id in self._graphs
158
+ key = key.id
159
+ if not isinstance(key, str):
160
+ return False
157
161
  return key in self._graphs
158
162
 
159
163
  def __repr__(self) -> str:
@@ -232,6 +232,9 @@ class Task:
232
232
  )
233
233
  raise ValueError(message)
234
234
 
235
+ if status in {TaskStatus.RUNNING, TaskStatus.SUCCEEDED}:
236
+ error = None
237
+
235
238
  self.error = error
236
239
  self._status = status
237
240
  if status == TaskStatus.RUNNING:
@@ -244,7 +247,7 @@ class Task:
244
247
  Cancellation is intentionally idempotent so duplicate user/API requests
245
248
  are harmless, while transition_to(CANCELLED) remains strict.
246
249
  """
247
- if self.status == TaskStatus.CANCELLED:
250
+ if self.status in {TaskStatus.SUCCEEDED, TaskStatus.CANCELLED}:
248
251
  return
249
252
 
250
253
  self.transition_to(TaskStatus.CANCELLED, error=self.error)
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.2.1'
22
+ __version_tuple__ = version_tuple = (0, 2, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -256,6 +256,25 @@ def test_execute_terminalizes_task_without_registered_handler() -> None:
256
256
  assert events[0].previous_status == TaskStatus.PENDING
257
257
 
258
258
 
259
+ def test_execute_without_handler_terminalizes_blocked_retry_cleanly() -> None:
260
+ """A retryable BLOCKED task without a handler fails with the handler error."""
261
+ executor = TaskExecutor.empty()
262
+ task = Task.bash("", title="Example")
263
+ task.transition_to(TaskStatus.BLOCKED)
264
+ events: list[TaskEvent] = []
265
+ executor.events.subscribe(events.append)
266
+
267
+ with pytest.raises(ValueError, match="No handler registered"):
268
+ executor.execute(task)
269
+
270
+ assert task.status == TaskStatus.FAILED
271
+ assert task.result is not None
272
+ assert task.result.termination_reason == "handler"
273
+ assert task.result.error == "No handler registered for task type 'bash'"
274
+ assert [e.type for e in events] == [TaskEventType.FAILED]
275
+ assert events[0].previous_status == TaskStatus.BLOCKED
276
+
277
+
259
278
  def test_execute_rejects_cancelled_task_without_calling_handler() -> None:
260
279
  """Cancelled tasks are rejected before any handler side effects occur."""
261
280
  executor = TaskExecutor()
@@ -575,6 +594,18 @@ def test_powershell_task_executes() -> None:
575
594
  assert not executor.is_running(task.id)
576
595
 
577
596
 
597
+ def test_bash_task_with_non_utf8_output_succeeds_with_replacement_text() -> None:
598
+ """Successful subprocesses are not failed by undecodable output bytes."""
599
+ executor = TaskExecutor()
600
+ task = Task.bash("python -c 'import sys; sys.stdout.buffer.write(bytes([255]))'")
601
+
602
+ result = executor.execute(task)
603
+
604
+ assert task.status == TaskStatus.SUCCEEDED
605
+ assert result.output == "�"
606
+ assert result.returncode == 0
607
+
608
+
578
609
  def test_bash_task_without_timeout_waits_for_completion() -> None:
579
610
  """timeout=None means the subprocess is allowed to run until it exits."""
580
611
  executor = TaskExecutor()
@@ -1393,6 +1424,20 @@ class TestTerminationReason:
1393
1424
  # ---- Step 15: public mark_blocked seam --------------------------------------
1394
1425
 
1395
1426
 
1427
+ def test_mark_blocked_rejection_does_not_set_blocked_by() -> None:
1428
+ """A failed mark_blocked() call must not leave stale block metadata."""
1429
+ executor = TaskExecutor()
1430
+ task = Task.bash("", title="Example")
1431
+ task.transition_to(TaskStatus.RUNNING)
1432
+ task.transition_to(TaskStatus.SUCCEEDED)
1433
+
1434
+ with pytest.raises(ValueError, match="Cannot transition task"):
1435
+ executor.mark_blocked(task, "parent-id-123")
1436
+
1437
+ assert task.status == TaskStatus.SUCCEEDED
1438
+ assert task.blocked_by is None
1439
+
1440
+
1396
1441
  def test_mark_blocked_transitions_and_emits_blocked_event() -> None:
1397
1442
  """executor.mark_blocked(task, parent_id) is the public scheduler seam."""
1398
1443
  executor = TaskExecutor()
@@ -116,6 +116,16 @@ class TestSQLiteTaskCollection:
116
116
  SQLiteStore(store_path).tasks.save(task)
117
117
  assert SQLiteStore(store_path).tasks[task.id].title == task.title
118
118
 
119
+ def test_memory_store_reuses_schema_across_operations(self) -> None:
120
+ """SQLiteStore(':memory:') works across the collection's connections."""
121
+ store = SQLiteStore(":memory:")
122
+ task = _bash("Memory", "echo memory")
123
+
124
+ store.tasks.save(task)
125
+
126
+ assert len(store.tasks) == 1
127
+ assert store.tasks[task.id].title == "Memory"
128
+
119
129
  def test_delitem_missing_task_raises_key_error(self, store: SQLiteStore) -> None:
120
130
  with pytest.raises(KeyError):
121
131
  del store.tasks["missing"]
@@ -51,6 +51,10 @@ class TestInMemoryTaskCollection:
51
51
  assert task in tasks
52
52
  assert "missing" not in tasks
53
53
 
54
+ def test_contains_returns_false_for_unhashable_non_keys(self) -> None:
55
+ tasks = InMemoryTaskCollection()
56
+ assert [] not in tasks
57
+
54
58
  def test_len_reflects_stored_tasks(self) -> None:
55
59
  tasks = InMemoryTaskCollection()
56
60
  assert len(tasks) == 0
@@ -109,6 +113,10 @@ class TestInMemoryGraphCollection:
109
113
  graphs.save(graph)
110
114
  assert graph in graphs
111
115
 
116
+ def test_contains_returns_false_for_unhashable_non_keys(self) -> None:
117
+ graphs = InMemoryGraphCollection()
118
+ assert [] not in graphs
119
+
112
120
  def test_setitem_rejects_non_graph(self) -> None:
113
121
  bogus = _opaque("not a graph")
114
122
  with pytest.raises(TypeError):
@@ -104,6 +104,17 @@ def test_status_changes_through_transition_to() -> None:
104
104
  assert task.error is None
105
105
 
106
106
 
107
+ def test_success_transition_clears_even_explicit_error_text() -> None:
108
+ """A SUCCEEDED task must not retain error text from a bad caller argument."""
109
+ task = Task.bash("echo hi", title="Example")
110
+ task.transition_to(TaskStatus.RUNNING)
111
+
112
+ task.transition_to(TaskStatus.SUCCEEDED, error="should be ignored")
113
+
114
+ assert task.status == TaskStatus.SUCCEEDED
115
+ assert task.error is None
116
+
117
+
107
118
  def test_cancel_changes_status_through_state_machine() -> None:
108
119
  """cancel() is a domain helper around the CANCELLED transition."""
109
120
  task = Task.bash("echo hi", title="Example")
@@ -123,6 +134,18 @@ def test_cancel_is_idempotent() -> None:
123
134
  assert task.status == TaskStatus.CANCELLED
124
135
 
125
136
 
137
+ def test_cancel_after_success_is_no_op() -> None:
138
+ """Cancelling an already-succeeded task leaves the success intact."""
139
+ task = Task.bash("echo hi", title="Example")
140
+ task.transition_to(TaskStatus.RUNNING)
141
+ task.transition_to(TaskStatus.SUCCEEDED)
142
+
143
+ task.cancel()
144
+
145
+ assert task.status == TaskStatus.SUCCEEDED
146
+ assert task.error is None
147
+
148
+
126
149
  def test_cancel_preserves_previous_error() -> None:
127
150
  """Cancelling a failed task keeps the failure reason for inspection."""
128
151
  task = Task.bash("echo hi", title="Example")
@@ -353,6 +353,26 @@ def test_diamond_runs_with_parallelism() -> None:
353
353
  # ---- Failure policy ----------------------------------------------------------
354
354
 
355
355
 
356
+ def test_required_executor_error_makes_graph_not_ok_even_if_status_succeeded() -> None:
357
+ """A required task future error makes graph.ok false even with success status."""
358
+
359
+ class BrokenExecutor(TaskExecutor):
360
+ def execute(self, task, upstream=None):
361
+ task.transition_to(TaskStatus.RUNNING)
362
+ task.transition_to(TaskStatus.SUCCEEDED)
363
+ raise RuntimeError("executor post-processing failed")
364
+
365
+ a = _bash("A", "echo a")
366
+ graph = TaskGraph()
367
+ graph[a] = []
368
+
369
+ graph.run(BrokenExecutor())
370
+
371
+ assert a.status == TaskStatus.SUCCEEDED
372
+ assert a.id in graph.errors
373
+ assert not graph.ok
374
+
375
+
356
376
  def test_graph_records_executor_errors() -> None:
357
377
  """Pre-start handler errors terminalize the task as FAILED with the error."""
358
378
  a = _bash("A", "echo a")
@@ -875,6 +895,20 @@ def test_add_after_accepts_arbitrary_iterables() -> None:
875
895
  assert graph.dependencies(b) == [a]
876
896
 
877
897
 
898
+ def test_add_deduplicates_repeated_dependencies() -> None:
899
+ """Repeated upstream tasks are one edge, not a false cycle."""
900
+ a = _bash("A", "echo a")
901
+ b = _bash("B", "echo b")
902
+ graph = TaskGraph()
903
+ graph.add(a)
904
+
905
+ graph.add(b, after=[a, a])
906
+
907
+ assert graph.dependencies(b) == [a]
908
+ graph.run(TaskExecutor())
909
+ assert graph.ok
910
+
911
+
878
912
  def test_add_finally_marks_task_as_finally_required_by_default() -> None:
879
913
  """finally_=True without required= keeps the task required."""
880
914
  a = _bash("A", "echo a")
@@ -960,6 +994,24 @@ def test_add_rejects_non_bool_required() -> None:
960
994
  # ---- Step 12: carryover-BLOCKED retry ----------------------------------------
961
995
 
962
996
 
997
+ def test_failed_parent_added_after_child_retries_before_child_is_blocked() -> None:
998
+ """A retryable failed parent can recover even when visited after its child."""
999
+ a = _bash("A", "echo a")
1000
+ b = _bash("B", "echo b")
1001
+ a.transition_to(TaskStatus.RUNNING)
1002
+ a.transition_to(TaskStatus.FAILED, error="previous failure")
1003
+ graph = TaskGraph()
1004
+ graph[b] = [a]
1005
+ graph[a] = []
1006
+
1007
+ graph.run(TaskExecutor())
1008
+
1009
+ assert graph.ok
1010
+ assert graph.blocked == []
1011
+ assert a.status == TaskStatus.SUCCEEDED
1012
+ assert b.status == TaskStatus.SUCCEEDED
1013
+
1014
+
963
1015
  def test_carryover_blocked_with_succeeded_parent_recovers() -> None:
964
1016
  """A BLOCKED task entering run() with all parents SUCCEEDED runs and succeeds."""
965
1017
  a = _bash("A", "echo a")
@@ -1027,6 +1079,32 @@ def test_within_run_blocked_is_not_retried_same_run() -> None:
1027
1079
  assert attempts["B"] == 0
1028
1080
 
1029
1081
 
1082
+ def test_finally_waits_for_retryable_failed_dependency_added_later() -> None:
1083
+ """A finally task waits for a retryable failed dependency to rerun first."""
1084
+ parent = _bash("parent", "")
1085
+ parent.transition_to(TaskStatus.RUNNING)
1086
+ parent.transition_to(TaskStatus.FAILED, error="old failure")
1087
+ cleanup = _bash("cleanup", "")
1088
+ seen: list[str] = []
1089
+
1090
+ executor = TaskExecutor.empty()
1091
+
1092
+ def handler(context: Any) -> str:
1093
+ seen.append(context.title)
1094
+ return "ok"
1095
+
1096
+ executor.register(TaskType.BASH, handler)
1097
+ graph = TaskGraph()
1098
+ graph.add(cleanup, after=[parent], finally_=True)
1099
+ graph[parent] = []
1100
+
1101
+ graph.run(executor, max_workers=1)
1102
+
1103
+ assert seen == ["parent", "cleanup"]
1104
+ assert parent.status == TaskStatus.SUCCEEDED
1105
+ assert cleanup.status == TaskStatus.SUCCEEDED
1106
+
1107
+
1030
1108
  def test_finally_runs_after_carryover_blocked_recovers() -> None:
1031
1109
  """A finally task fires after a carryover-BLOCKED task recovers to SUCCEEDED."""
1032
1110
  finally_ran: list[str] = []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes