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.
- {ttasks-0.2.0 → ttasks-0.2.1}/.github/workflows/docs.yml +3 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/.github/workflows/publish.yml +3 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/PKG-INFO +3 -5
- {ttasks-0.2.0 → ttasks-0.2.1}/README.md +2 -4
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_executor.py +7 -2
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_graph.py +25 -6
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_sqlite.py +21 -2
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_store.py +6 -2
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_task.py +4 -1
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_version.py +2 -2
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_executor.py +45 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_sqlite_store.py +10 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_store.py +8 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_task.py +23 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_workflow.py +78 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/.gitignore +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/.python-version +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/.vscode/launch.json +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/.vscode/settings.json +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/main.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/pyproject.toml +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/scripts/preflight.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/__init__.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_events.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/_exceptions.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/src/ttasks/py.typed +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/conftest.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_e2e.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_events.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_public_api.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/tests/test_task_factories.py +0 -0
- {ttasks-0.2.0 → ttasks-0.2.1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ttasks
|
|
3
|
-
Version: 0.2.
|
|
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=
|
|
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
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
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
|
|
File without changes
|