ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl

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 (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/item_passes.py ADDED
@@ -0,0 +1,167 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """What an expanded ``items`` pass requires of its items when it ends.
3
+
4
+ A workflow has one item collection and any number of sequential ``items``
5
+ passes over it. Each pass declares what its stages do with an item through
6
+ ``item_phase``, and only that is required when the run leaves the pass: an
7
+ analyze stage needs the item's analysis, a resolve stage its actual solution
8
+ and ``resolved``, a report stage ``reported``, and a phase stage's declared
9
+ item saves their values. The built-in ``handle-item`` stage keeps its whole
10
+ lifecycle: ``resolved`` and ``reported``. A stage without ``item_phase``
11
+ has only its ordinary completion contract. A stage that never ran, because
12
+ an assessment, a break, or a stop skipped it, requires nothing. When the
13
+ pass ends with an assessment, it is left only once the outcome is chosen.
14
+
15
+ A linked item (``reference_to_id``) shares its canonical item's analysis and
16
+ solution, so a duplicate comment needs no duplicate fix, but it is reported,
17
+ and resolved, for its own source.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ww.execution_models import PlanItemExecution
23
+ from ww.items import WorkItem
24
+ from ww.plan import PlanItem, WorkflowPlan
25
+
26
+
27
+ def item_collection(plan: WorkflowPlan) -> PlanItem | None:
28
+ """The first ``items`` declaration, which holds the collection's settings.
29
+
30
+ Every pass works on the same collection; its ``persistent``,
31
+ ``identity``, and ``unique`` settings are the first declaration's. The
32
+ shared validator rejects a later declaration that sets them differently.
33
+ """
34
+ return next(
35
+ (
36
+ item
37
+ for item in plan.items
38
+ if item.item_operation == "collect" and item.child_operation is None
39
+ ),
40
+ None,
41
+ )
42
+
43
+
44
+ def is_pass_stage(item: PlanItem) -> bool:
45
+ """Whether ``item`` is a concrete stage, hook, or verifier of an item pass."""
46
+ return (
47
+ item.item_pass is not None
48
+ and item.item_id is not None
49
+ and item.child_stage is None
50
+ and not item.item_template
51
+ )
52
+
53
+
54
+ def reports_item_on_completion(plan: WorkflowPlan, cursor: int) -> bool:
55
+ """Whether completing ``plan.items[cursor]`` finishes an automatic report.
56
+
57
+ A report stage's lifecycle is its step, its handler-group members and its
58
+ completion hooks: the plan items of one item and pass with the report
59
+ operation. When ww runs any of them, the item is reported by the last one
60
+ to complete, whoever owns that last item, so no agent bookkeeping is
61
+ needed. A report stage that ww runs none of is reported by its agent
62
+ with ``update-item --reported=true``, as before.
63
+ """
64
+ current = plan.items[cursor]
65
+ if current.item_operation != "report_item" or current.item_id is None:
66
+ return False
67
+ lifecycle = [
68
+ other
69
+ for other in plan.items
70
+ if other.item_id == current.item_id
71
+ and other.item_pass == current.item_pass
72
+ and other.item_operation == "report_item"
73
+ ]
74
+ return lifecycle[-1].id == current.id and any(
75
+ other.owner == "ww" for other in lifecycle
76
+ )
77
+
78
+
79
+ def leaving_pass(plan: WorkflowPlan, cursor: int) -> str | None:
80
+ """The pass whose expanded stages end right before ``cursor``, if any.
81
+
82
+ Assessment outcomes inside a per-item stage carry the stage's item and
83
+ pass, so they count as stages of the pass. Every workflow without a
84
+ handoff ends with its built-in summary, so the run never leaves a pass by
85
+ reaching the end of the plan; a stopping outcome completes the run.
86
+ """
87
+ if not 0 < cursor < len(plan.items):
88
+ return None
89
+ previous, following = plan.items[cursor - 1], plan.items[cursor]
90
+ if not is_pass_stage(previous) or (
91
+ is_pass_stage(following) and following.item_pass == previous.item_pass
92
+ ):
93
+ return None
94
+ return previous.item_pass
95
+
96
+
97
+ def pass_gate_failures(
98
+ plan: WorkflowPlan,
99
+ records: tuple[PlanItemExecution, ...],
100
+ pass_id: str,
101
+ items: tuple[WorkItem, ...],
102
+ ) -> tuple[str, ...]:
103
+ """What each item of ``pass_id`` still lacks for the stages that ran."""
104
+ by_id = {item.id: item for item in items}
105
+ failures: list[str] = []
106
+ for stage, record in zip(plan.items, records, strict=True):
107
+ if (
108
+ not is_pass_stage(stage)
109
+ or stage.item_pass != pass_id
110
+ or stage.phase != "step"
111
+ or stage.verifies is not None
112
+ or stage.item_operation is None
113
+ or record.status != "completed"
114
+ or record.started_at is None
115
+ ):
116
+ continue
117
+ item = by_id.get(str(stage.item_id))
118
+ if item is None:
119
+ failures.append(f"{stage.item_id} ({stage.name}): the item is gone")
120
+ continue
121
+ missing = _missing(stage, item, by_id)
122
+ if missing:
123
+ failures.append(f"{item.id} ({stage.name}): " + ", ".join(missing))
124
+ return tuple(dict.fromkeys(failures))
125
+
126
+
127
+ def _missing(
128
+ stage: PlanItem, item: WorkItem, items: dict[str, WorkItem]
129
+ ) -> tuple[str, ...]:
130
+ canonical = _canonical(item, items)
131
+ missing: list[str] = []
132
+ operation = stage.item_operation
133
+ if operation == "process_item" and not (
134
+ item.processed_item or canonical.processed_item
135
+ ):
136
+ missing.append("processed_item")
137
+ if operation == "resolve_item":
138
+ if not (item.actual_solution or canonical.actual_solution):
139
+ missing.append("actual_solution")
140
+ if not (item.resolved or canonical.resolved):
141
+ missing.append("resolved=true")
142
+ if operation == "report_item" and not item.reported:
143
+ missing.append("reported=true")
144
+ if operation == "handle_item":
145
+ if not item.resolved:
146
+ missing.append("resolved=true")
147
+ if not item.reported:
148
+ missing.append("reported=true")
149
+ missing.extend(
150
+ f"field {field.name}"
151
+ for field in stage.update_item
152
+ if not item.field(field.name)
153
+ )
154
+ return tuple(missing)
155
+
156
+
157
+ def _canonical(item: WorkItem, items: dict[str, WorkItem]) -> WorkItem:
158
+ """The item a linked item refers to, following links; itself otherwise."""
159
+ seen = {item.id}
160
+ current = item
161
+ while current.reference_to_id is not None:
162
+ target = items.get(current.reference_to_id)
163
+ if target is None or target.id in seen:
164
+ break
165
+ seen.add(target.id)
166
+ current = target
167
+ return current
ww/items.py ADDED
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Durable, run-local work items used by item-aware workflow steps."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from dataclasses import dataclass, fields, replace
8
+
9
+ # An item field name, e.g. "acceptance_criteria" or "due-date"; "2nd" does not
10
+ # match.
11
+ FIELD_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class WorkItem:
16
+ """One collected unit of work inside a run."""
17
+
18
+ id: str
19
+ item: str
20
+ processed_item: str = ""
21
+ proposed_solution: str = ""
22
+ actual_solution: str = ""
23
+ resolved: bool = False
24
+ reported: bool = False
25
+ reference_to_id: str | None = None
26
+ # Custom fields a workflow declares for its items, such as the ID of the
27
+ # source comment or of the reply posted for it; string values only.
28
+ fields: tuple[tuple[str, str], ...] = ()
29
+
30
+ def field(self, name: str) -> str | None:
31
+ return dict(self.fields).get(name)
32
+
33
+ def with_fields(self, values: dict[str, str]) -> WorkItem:
34
+ merged = {**dict(self.fields), **values}
35
+ return replace(self, fields=tuple(merged.items()))
36
+
37
+ def to_dict(self) -> dict[str, object]:
38
+ return {
39
+ "id": self.id,
40
+ "item": self.item,
41
+ "processed_item": self.processed_item,
42
+ "proposed_solution": self.proposed_solution,
43
+ "actual_solution": self.actual_solution,
44
+ "resolved": self.resolved,
45
+ "reported": self.reported,
46
+ "reference_to_id": self.reference_to_id,
47
+ "fields": dict(self.fields),
48
+ }
49
+
50
+ @classmethod
51
+ def from_dict(cls, data: object) -> WorkItem:
52
+ if not isinstance(data, dict):
53
+ raise ValueError("item must be a mapping")
54
+
55
+ if not isinstance(data.get("id"), str) or not data["id"].strip():
56
+ raise ValueError("item ID must be a non-empty string")
57
+ if not isinstance(data.get("item"), str) or not data["item"].strip():
58
+ raise ValueError("item text must be a non-empty string")
59
+ processed = data.get("processed_item", "")
60
+ proposed = data.get("proposed_solution", "")
61
+ actual = data.get("actual_solution", "")
62
+ if not all(isinstance(value, str) for value in (processed, proposed, actual)):
63
+ raise ValueError("item text fields must be strings")
64
+ resolved = data.get("resolved", False)
65
+ reported = data.get("reported", False)
66
+ if not isinstance(resolved, bool) or not isinstance(reported, bool):
67
+ raise ValueError("item resolved and reported fields must be booleans")
68
+ reference = data.get("reference_to_id")
69
+ if reference is not None and (not isinstance(reference, str) or not reference):
70
+ raise ValueError("item reference_to_id must be a non-empty string or null")
71
+ return cls(
72
+ id=data["id"],
73
+ item=data["item"],
74
+ processed_item=processed,
75
+ proposed_solution=proposed,
76
+ actual_solution=actual,
77
+ resolved=resolved,
78
+ reported=reported,
79
+ reference_to_id=reference,
80
+ fields=validate_item_fields(data.get("fields", {})),
81
+ )
82
+
83
+
84
+ def validate_item_fields(data: object) -> tuple[tuple[str, str], ...]:
85
+ """Custom fields are a mapping of valid names to strings."""
86
+ if not isinstance(data, dict):
87
+ raise ValueError("item fields must be a mapping")
88
+ for name, value in data.items():
89
+ if not isinstance(name, str) or not FIELD_NAME.fullmatch(name):
90
+ raise ValueError(f"invalid item field name: {name!r}")
91
+ if not isinstance(value, str):
92
+ raise ValueError(f"item field {name!r} must be a string")
93
+ return tuple(data.items())
94
+
95
+
96
+ # Fields an agent may update after collection; identity and the item text are fixed.
97
+ EDITABLE_WORK_ITEM_FIELDS = frozenset(
98
+ field.name for field in fields(WorkItem) if field.name not in {"id", "item"}
99
+ )
ww/locking.py ADDED
@@ -0,0 +1,207 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Exclusive file locking and atomic replacement for ww's writes.
3
+
4
+ Two ww invocations in one project are unrelated processes competing for the
5
+ same task files. Atomic replacement alone is not enough: it stops a reader
6
+ seeing half a write, but not two processes reading one ``state.json``, each
7
+ deciding the next item, and one overwriting the other. The fix is a single
8
+ exclusive lock per task, held by ``WorkflowService`` for the whole of ``start``,
9
+ ``next``, ``complete`` or ``reset`` — the span, not the individual write.
10
+
11
+ The task scope is the primary coordination boundary; the project scope wraps
12
+ ``ww init``, project metadata has a dedicated merge lock, the execution log has
13
+ its own lock, and aggregate commits use a separate per-aggregate CAS lock for
14
+ direct storage-adapter callers. Everything else writes inside one of those scopes, so
15
+ ``atomic_write`` takes no lock of its own. A shared activity gate wraps those
16
+ locks; maintenance takes it exclusively before pruning sidecars.
17
+
18
+ Reads are deliberately unlocked. Replacement is atomic, so a reader always sees
19
+ a complete aggregate document. ``RunCoordinator.load`` selects the requested
20
+ run, execution state, and plan snapshot from one decoded revision rather than
21
+ combining independently read files. Read-only commands therefore do not wait
22
+ behind a mid-flight writer.
23
+
24
+ Locks are advisory POSIX locks on sidecar files under ``.ww/locks/``. The
25
+ kernel releases them when a process exits, so a killed run leaves nothing
26
+ stale. Waiting order is unspecified — no operating system promises FIFO — and
27
+ every wait is bounded by ``WW_LOCK_TIMEOUT`` (seconds, default 30; ``0`` waits
28
+ indefinitely) so a pathological wait fails loudly instead of hanging.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import errno
34
+ import fcntl
35
+ import hashlib
36
+ import os
37
+ import sys
38
+ import tempfile
39
+ import time
40
+ from collections.abc import Iterator
41
+ from contextlib import contextmanager
42
+ from pathlib import Path
43
+ from typing import TextIO
44
+
45
+ from ww.errors import LockError
46
+
47
+ TIMEOUT_VARIABLE = "WW_LOCK_TIMEOUT"
48
+ DEFAULT_TIMEOUT_SECONDS = 30.0
49
+ _NOTICE_AFTER_SECONDS = 0.25
50
+ _MAXIMUM_POLL_SECONDS = 0.05
51
+
52
+
53
+ class FileLocks:
54
+ """Exclusive locks and atomic writes for one project root."""
55
+
56
+ def __init__(self, root: Path) -> None:
57
+ self.root = Path(root)
58
+ self.directory = self.root / ".ww" / "locks"
59
+
60
+ def lock_path(self, target: Path) -> Path:
61
+ """Return the sidecar lock file that guards ``target``.
62
+
63
+ The lock never sits next to the file it guards: atomic replacement
64
+ swaps the target's inode and `reset` deletes whole task directories, so
65
+ a sidecar inside the task tree would be destroyed by the very writes it
66
+ is meant to protect.
67
+ """
68
+ absolute = os.path.normpath(Path(target).absolute())
69
+ digest = hashlib.sha256(absolute.encode("utf-8")).hexdigest()[:32]
70
+ return self.directory / f"{digest}.lock"
71
+
72
+ @property
73
+ def _activity_path(self) -> Path:
74
+ return self.directory / ".activity.lock"
75
+
76
+ @contextmanager
77
+ def lock(self, target: Path, *, purpose: str | None = None) -> Iterator[None]:
78
+ """Hold an exclusive lock on ``target`` for the duration of the block.
79
+
80
+ Not reentrant: ww takes each lock at exactly one place.
81
+ """
82
+ path = self.lock_path(target)
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ # Closing a handle releases its lock, including when the block raised.
85
+ with self._activity_path.open("a+", encoding="utf-8") as activity:
86
+ # Cleanup takes the activity gate exclusively before unlinking
87
+ # sidecars. Do not open the task sidecar until the shared gate is
88
+ # held: otherwise cleanup can unlink it between open(2) and the
89
+ # shared-lock acquisition, leaving this process locking an orphan
90
+ # inode while a later caller locks the replacement path.
91
+ _acquire(activity, "ww activity gate", fcntl.LOCK_SH)
92
+ with path.open("a+", encoding="utf-8") as handle:
93
+ _acquire(handle, purpose or self._describe(target))
94
+ yield
95
+
96
+ def cleanup(self) -> int:
97
+ """Remove unused lock sidecars without racing active or waiting users."""
98
+ self.directory.mkdir(parents=True, exist_ok=True)
99
+ with self._activity_path.open("a+", encoding="utf-8") as activity:
100
+ _acquire(activity, "ww activity gate", fcntl.LOCK_EX)
101
+ removed = 0
102
+ for path in self.directory.glob("*.lock"):
103
+ if path == self._activity_path:
104
+ continue
105
+ path.unlink(missing_ok=True)
106
+ removed += 1
107
+ return removed
108
+
109
+ def atomic_write(self, target: Path, content: str) -> None:
110
+ """Replace ``target`` with ``content`` in one step.
111
+
112
+ This takes no lock. Callers write inside a scope their command already
113
+ holds; the atomicity here is what keeps a concurrent *reader* from ever
114
+ seeing a partial file.
115
+ """
116
+ target = Path(target)
117
+ target.parent.mkdir(parents=True, exist_ok=True)
118
+ descriptor, temporary_name = tempfile.mkstemp(
119
+ prefix=f".{target.name}.", dir=target.parent
120
+ )
121
+ temporary = Path(temporary_name)
122
+ try:
123
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
124
+ handle.write(content)
125
+ handle.flush()
126
+ os.fsync(handle.fileno())
127
+ temporary.replace(target)
128
+ directory_descriptor = os.open(target.parent, os.O_RDONLY)
129
+ try:
130
+ os.fsync(directory_descriptor)
131
+ finally:
132
+ os.close(directory_descriptor)
133
+ except BaseException:
134
+ temporary.unlink(missing_ok=True)
135
+ raise
136
+
137
+ def append_line(self, target: Path, line: str) -> None:
138
+ """Append one newline-terminated record under ``target``'s own lock."""
139
+ target = Path(target)
140
+ target.parent.mkdir(parents=True, exist_ok=True)
141
+ with self.lock(target), target.open("a", encoding="utf-8") as handle:
142
+ handle.write(line if line.endswith("\n") else line + "\n")
143
+ handle.flush()
144
+ os.fsync(handle.fileno())
145
+
146
+ def _describe(self, target: Path) -> str:
147
+ try:
148
+ return str(Path(target).relative_to(self.root))
149
+ except ValueError:
150
+ return str(target)
151
+
152
+
153
+ def timeout_seconds() -> float | None:
154
+ """Return the configured wait bound, or ``None`` to wait indefinitely."""
155
+ raw = os.environ.get(TIMEOUT_VARIABLE)
156
+ if raw is None:
157
+ return DEFAULT_TIMEOUT_SECONDS
158
+ try:
159
+ value = float(raw)
160
+ except ValueError as error:
161
+ raise LockError(
162
+ f"invalid {TIMEOUT_VARIABLE}: {raw!r} is not a number of seconds"
163
+ ) from error
164
+ return None if value <= 0 else value
165
+
166
+
167
+ _CONTENDED_ERRNOS = {
168
+ errno.EAGAIN,
169
+ errno.EWOULDBLOCK,
170
+ errno.EACCES,
171
+ errno.EINTR,
172
+ }
173
+
174
+
175
+ def _is_contended_error(error: OSError) -> bool:
176
+ return isinstance(error, BlockingIOError) or error.errno in _CONTENDED_ERRNOS
177
+
178
+
179
+ def _acquire(handle: TextIO, description: str, mode: int = fcntl.LOCK_EX) -> None:
180
+ limit = timeout_seconds()
181
+ started = time.monotonic()
182
+ deadline = None if limit is None else started + limit
183
+ announced = False
184
+ delay = 0.001
185
+ last_contention_error: OSError | None = None
186
+ while True:
187
+ try:
188
+ fcntl.flock(handle.fileno(), mode | fcntl.LOCK_NB)
189
+ return
190
+ except OSError as error:
191
+ if not _is_contended_error(error):
192
+ raise LockError(f"cannot lock {description}: {error}") from error
193
+ last_contention_error = error
194
+ if deadline is not None and time.monotonic() >= deadline:
195
+ raise LockError(
196
+ f"timed out after {limit:g}s waiting for another ww process to "
197
+ f"release {description}; set {TIMEOUT_VARIABLE} to wait longer "
198
+ "(or 0 to wait indefinitely)"
199
+ ) from last_contention_error
200
+ if not announced and time.monotonic() - started >= _NOTICE_AFTER_SECONDS:
201
+ sys.stderr.write(
202
+ f"ww: waiting for another ww process to release {description}\n"
203
+ )
204
+ sys.stderr.flush()
205
+ announced = True
206
+ time.sleep(delay)
207
+ delay = min(delay * 2, _MAXIMUM_POLL_SECONDS)