@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.0

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 (92) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +18 -5
  3. package/SECURITY.md +25 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  79. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  86. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  90. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  91. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
@@ -0,0 +1,324 @@
1
+ """Work a service holds by its own limits, said to its caller and its operator (FAA-298). Part of the service kit
2
+ (layer L3, after fabric_interop).
3
+
4
+ A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
5
+ says so three ways:
6
+
7
+ - to the caller, on the job: `waiting_message(reason, since, until, message)` is the job's `statusMessage`. The pinned
8
+ `interop-job` schema is closed (`additionalProperties: false`), so `waiting {reason, since, until, message}` cannot
9
+ be a field of the job; it rides the contract's pause grammar (DEC-0087) as its text:
10
+ `quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`. A caller that reads only the pause
11
+ grammar waits (and until `until`, when given); `read_waiting(answer)` gives the four parts back. `JobStore.pause`
12
+ takes the same statusMessage through `hold_job`.
13
+ - to the operator, once per hold: `HoldBook.hold` opens a hold per (reason, caller), or joins the open one, and returns
14
+ the push only when it opens: one `notify` event naming the caller, the work, the reason and when the hold frees by
15
+ itself, with the question whether to raise the limit. Further jobs held by the same limit for the same caller join
16
+ the hold silently; a hold is closed by `release`, by its `until` passing, or by a lift that covers it.
17
+ - the answer, as a scoped lift: `HoldBook.lift` records the operator's words (a DEC-0121 decision, action
18
+ `limit.lift`) with its scope (the reason, optionally one caller and one job) and its size (a number of jobs, an
19
+ amount of money, or a raised limit for a time window such as 100 a day for today). `admit` spends a jobs or money
20
+ lift; `limit` gives the limit in force. A lift falls back by itself when its window ends or its amount is spent.
21
+
22
+ `HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
23
+ lock. It holds caller names, work labels, reasons and the operator's words, never a credential: `hold` refuses a text
24
+ the contract's detector reads as one.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import re
30
+ import uuid
31
+ from pathlib import Path
32
+ from typing import Any, Callable, Dict, List, Optional
33
+
34
+ import sys as _sys # noqa: E402
35
+ from pathlib import Path as _Path # noqa: E402
36
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
37
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
38
+ from fabric_base import ServiceError, now_iso, store_lock # noqa: E402
39
+ from fabric_paths import atomic_write # noqa: E402
40
+ from fabric_actions import decision_shape # noqa: E402
41
+ from fabric_interop_rules import pause_message, read_job_pause, utc_time, QUOTA_EXHAUSTED # noqa: E402
42
+ import fabric_secrets # noqa: E402
43
+
44
+ #: The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap.
45
+ HOLD_REASONS = ("daily-quota", "caller-share", "concurrency", "money")
46
+ #: How the push names each reason to a person.
47
+ REASON_WORDS = {"daily-quota": "the daily quota", "caller-share": "its per-caller share",
48
+ "concurrency": "the concurrency cap", "money": "the money cap"}
49
+ #: The push's kind: a decision for the operator (level notice, notify true; events-and-notifications.md).
50
+ HOLD_EVENT_KIND = "limit.held"
51
+ #: The record of a lift applied (level info, no notify).
52
+ LIFT_EVENT_KIND = "limit.lifted"
53
+ #: The decision action a lift carries (DEC-0121 AP-2 shape).
54
+ LIFT_ACTION = "limit.lift"
55
+ _MAX_STATUS_MESSAGE = 500
56
+ _WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
57
+ _SIZES = ("jobs", "usd", "limit")
58
+ #: One run of ECMAScript's whitespace, the class both twins squash with (FAA-316): Python's str.split() also takes
59
+ #: U+001C to U+001F and U+0085 and not U+FEFF, so the twins wrote different statusMessages.
60
+ _SPACES = re.compile("[%s]+" % fabric_secrets.JS_SPACE)
61
+
62
+
63
+ def _squash(text: str) -> str:
64
+ """Runs of ECMAScript whitespace as one space, none at either end (the Node twin's `squash`)."""
65
+ return _SPACES.sub(" ", text).strip(" ")
66
+
67
+
68
+ def _instant(value: Any, name: str) -> str:
69
+ if utc_time(value) is None:
70
+ raise ServiceError("%s is a UTC instant (Z, at most 3 fraction digits)." % name)
71
+ return value
72
+
73
+
74
+ def waiting_message(reason: str, since: str, until: Optional[str] = None, message: Optional[str] = None) -> str:
75
+ """A held job's statusMessage: the pause grammar, its text `waiting <reason> since <since>[: <message>]`. The
76
+ message is shortened, never refused, to fit the contract's 500 characters; a reason or an instant outside the
77
+ grammar raises ServiceError."""
78
+ if reason not in HOLD_REASONS:
79
+ raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
80
+ _instant(since, "since")
81
+ if until is not None:
82
+ _instant(until, "until")
83
+ text = "waiting %s since %s" % (reason, since)
84
+ head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
85
+ if message is not None:
86
+ message = _squash(str(message))
87
+ room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
88
+ if message and room > 0:
89
+ text += ": " + (message if len(message) <= room else message[:room - 3].rstrip(" ") + "...")
90
+ return pause_message(until, text)
91
+
92
+
93
+ def read_waiting(answer: Any) -> Optional[Dict[str, Any]]:
94
+ """`{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds (`waiting_message`), else
95
+ None: a plain pause, a queue position or a finished job is not a hold."""
96
+ pause = read_job_pause(answer)
97
+ if not pause.get("paused"):
98
+ return None
99
+ status = answer["job"]["statusMessage"]
100
+ rest = status[len(QUOTA_EXHAUSTED):]
101
+ if "resetsAt" in pause:
102
+ rest = rest[len(" until ") + len(pause["resetsAt"]):]
103
+ if not rest.startswith(": "):
104
+ return None
105
+ match = _WAITING.match(rest[2:])
106
+ if not match or utc_time(match.group(2)) is None:
107
+ return None
108
+ waiting: Dict[str, Any] = {"reason": match.group(1), "since": match.group(2)}
109
+ if "resetsAt" in pause:
110
+ waiting["until"] = pause["resetsAt"]
111
+ if match.group(3) is not None:
112
+ waiting["message"] = match.group(3)
113
+ return waiting
114
+
115
+
116
+ def hold_job(jobs: Any, job_id: str, reason: str, since: str, until: Optional[str] = None,
117
+ message: Optional[str] = None) -> Dict[str, Any]:
118
+ """Put one job of a fabric_interop JobStore on hold: working, its statusMessage `waiting_message(...)`, its
119
+ pollIntervalMs reaching `until` (JobStore.pause). Returns the job."""
120
+ text = waiting_message(reason, since, until, message)[len(QUOTA_EXHAUSTED):]
121
+ if until is not None:
122
+ text = text[len(" until ") + len(until):]
123
+ return jobs.pause(job_id, resets_at=until, text=text[2:])
124
+
125
+
126
+ def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) -> str:
127
+ """The push's one sentence: who, what, why, when it frees by itself, and the question."""
128
+ frees = ("it frees by itself at %s" % until) if until is not None else (
129
+ "it frees by itself when a running job ends" if reason in ("concurrency", "caller-share")
130
+ else "it does not free by itself")
131
+ return "%s for %s is held by %s; %s. Raise the limit for it?" % (work, caller, REASON_WORDS[reason], frees)
132
+
133
+
134
+ def _text(value: Any, name: str, high: int = 200) -> str:
135
+ if not isinstance(value, str) or not _squash(value) or len(value) > high:
136
+ raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
137
+ if fabric_secrets.secret_value(value):
138
+ raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
139
+ return _squash(value)
140
+
141
+
142
+ class HoldBook:
143
+ """The holds a service's limits put on work and the operator's lifts, in one file (FAA-298).
144
+
145
+ `events`, when given, is the service's event log (an object with `append(kind, level, text, subject=..., **extra)`,
146
+ such as fabric_events.JsonlEventLog): the push and the lift record are written there. Without it they are returned
147
+ for the service to write."""
148
+
149
+ def __init__(self, path: Path, events: Any = None, clock: Callable[[], str] = now_iso):
150
+ self.path = Path(path)
151
+ self.events = events
152
+ self.clock = clock
153
+ self._lock = store_lock(self.path)
154
+
155
+ # --- the file -------------------------------------------------------------------------
156
+ def _load(self) -> Dict[str, Any]:
157
+ if not self.path.exists():
158
+ return {"holds": [], "lifts": []}
159
+ try:
160
+ data = json.loads(self.path.read_text(encoding="utf-8"))
161
+ except ValueError:
162
+ raise ServiceError("The hold book %s is not JSON; it is kept as it is." % self.path.name) from None
163
+ if not isinstance(data, dict) or not isinstance(data.get("holds"), list) or not isinstance(data.get("lifts"), list):
164
+ raise ServiceError("The hold book %s is not {holds, lifts}; it is kept as it is." % self.path.name)
165
+ return data
166
+
167
+ def _save(self, data: Dict[str, Any]) -> None:
168
+ atomic_write(self.path, (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode(), 0o600)
169
+
170
+ def _now(self, now: Optional[str]) -> str:
171
+ return _instant(now if now is not None else self.clock(), "now")
172
+
173
+ def _emit(self, kind: str, level: str, text: str, subject: Dict[str, str], notify: bool) -> Dict[str, Any]:
174
+ if self.events is not None:
175
+ return self.events.append(kind, level, text, subject=subject, notify=notify)
176
+ out: Dict[str, Any] = {"kind": kind, "level": level, "text": text, "subject": subject}
177
+ if notify:
178
+ out["notify"] = True
179
+ return out
180
+
181
+ @staticmethod
182
+ def _expire(data: Dict[str, Any], at: int) -> None:
183
+ for hold in data["holds"]:
184
+ if hold.get("closed") is None and hold.get("until") is not None and utc_time(hold["until"]) <= at:
185
+ hold["closed"], hold["closedAt"] = "expired", hold["until"]
186
+
187
+ @staticmethod
188
+ def _live(lift: Dict[str, Any], at: int) -> bool:
189
+ if lift.get("from") is not None and utc_time(lift["from"]) > at:
190
+ return False
191
+ if lift.get("until") is not None and utc_time(lift["until"]) <= at:
192
+ return False
193
+ return lift.get("left", 1) > 0
194
+
195
+ # --- holds ----------------------------------------------------------------------------
196
+ def hold(self, *, caller: str, work: str, reason: str, until: Optional[str] = None, message: Optional[str] = None,
197
+ job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
198
+ """Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
199
+ call opened the hold, None when the work joined an open one (one push per hold)."""
200
+ caller, work = _text(caller, "caller"), _text(work, "work")
201
+ if reason not in HOLD_REASONS:
202
+ raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
203
+ at = self._now(now)
204
+ if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
205
+ raise ServiceError("until %s has passed: the limit no longer holds the work." % until)
206
+ with self._lock:
207
+ data = self._load()
208
+ self._expire(data, utc_time(at))
209
+ hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
210
+ and h["caller"] == caller), None)
211
+ opened = hold is None
212
+ if opened:
213
+ hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
214
+ "until": until, "jobs": [], "closed": None}
215
+ data["holds"].append(hold)
216
+ if job_id is not None and job_id not in hold["jobs"]:
217
+ hold["jobs"].append(str(job_id))
218
+ self._save(data)
219
+ status = waiting_message(reason, hold["since"], hold["until"], message)
220
+ push = None
221
+ if opened:
222
+ push = self._emit(HOLD_EVENT_KIND, "notice", hold_sentence(caller, work, reason, until),
223
+ {"type": "limit", "id": "%s:%s" % (reason, caller)[:200], "label": REASON_WORDS[reason]}, True)
224
+ return {"hold": dict(hold), "statusMessage": status, "push": push}
225
+
226
+ def release(self, *, reason: str, caller: str, now: Optional[str] = None) -> Optional[Dict[str, Any]]:
227
+ """Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again."""
228
+ at = self._now(now)
229
+ with self._lock:
230
+ data = self._load()
231
+ hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
232
+ and h["caller"] == caller), None)
233
+ if hold is None:
234
+ return None
235
+ hold["closed"], hold["closedAt"] = "released", at
236
+ self._save(data)
237
+ return dict(hold)
238
+
239
+ def open_holds(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
240
+ data = self._load()
241
+ self._expire(data, utc_time(self._now(now)))
242
+ return [dict(h) for h in data["holds"] if h.get("closed") is None]
243
+
244
+ # --- lifts ----------------------------------------------------------------------------
245
+ def lift(self, *, reason: str, decision: Dict[str, Any], caller: Optional[str] = None, job_id: Optional[str] = None,
246
+ jobs: Optional[int] = None, usd: Optional[float] = None, limit: Optional[int] = None,
247
+ until: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
248
+ """Record the operator's answer as a scoped lift: exactly one of `jobs` (that many more jobs), `usd` (that much
249
+ more money) or `limit` (the limit raised to that value, which needs `until`, the window's end). `decision` is
250
+ the operator's words in decision.schema.json's shape with action `limit.lift`. The open hold it covers closes
251
+ as lifted. Returns the lift."""
252
+ if reason not in HOLD_REASONS:
253
+ raise ServiceError("A lift's reason is one of %s." % ", ".join(HOLD_REASONS))
254
+ if not decision_shape(decision) or decision.get("action") != LIFT_ACTION:
255
+ raise ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
256
+ "words verbatim): fabric_actions.operator_decision('limit.lift', words).")
257
+ sizes = {"jobs": jobs, "usd": usd, "limit": limit}
258
+ given = [name for name in _SIZES if sizes[name] is not None]
259
+ if len(given) != 1:
260
+ raise ServiceError("A lift is one of jobs, usd or limit.")
261
+ size = sizes[given[0]]
262
+ if isinstance(size, bool) or not isinstance(size, (int, float)) or size <= 0 or (
263
+ given[0] != "usd" and int(size) != size):
264
+ raise ServiceError("A lift's %s is a positive %s." % (given[0], "number" if given[0] == "usd" else "integer"))
265
+ at = self._now(now)
266
+ if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
267
+ raise ServiceError("A lift's window ends after now.")
268
+ if given[0] == "limit" and until is None:
269
+ raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
270
+ record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
271
+ "job": None if job_id is None else str(job_id), "from": at, "until": until,
272
+ given[0]: size, "decision": dict(decision), "at": at}
273
+ if given[0] != "limit":
274
+ record["left"] = size
275
+ with self._lock:
276
+ data = self._load()
277
+ self._expire(data, utc_time(at))
278
+ for hold in data["holds"]:
279
+ if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
280
+ job_id is None or str(job_id) in hold["jobs"]):
281
+ hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
282
+ data["lifts"].append(record)
283
+ self._save(data)
284
+ scope = ("for %s" % record["caller"]) if record["caller"] else "for every caller"
285
+ amount = {"jobs": "%s more job(s)", "usd": "USD %s more", "limit": "the limit at %s"}[given[0]] % size
286
+ self._emit(LIFT_EVENT_KIND, "info", "The operator lifted %s %s: %s%s." % (
287
+ REASON_WORDS[reason], scope, amount, (" until %s" % until) if until else ""),
288
+ {"type": "limit", "id": "%s:%s" % (reason, record["caller"] or "*"), "label": REASON_WORDS[reason]}, False)
289
+ return dict(record)
290
+
291
+ def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
292
+ kind: str) -> List[Dict[str, Any]]:
293
+ return [l for l in data["lifts"] if l["reason"] == reason and kind in l and self._live(l, at)
294
+ and l.get("caller") in (None, caller) and l.get("job") in (None, job_id)]
295
+
296
+ def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
297
+ now: Optional[str] = None) -> Dict[str, Any]:
298
+ """Whether a lift lets work past the limit that would hold it: {admitted: True, lift} spends one job of a jobs
299
+ lift, or `cost_usd` of a money lift (the narrowest scope first: a job's, a caller's, every caller's), else
300
+ {admitted: False}. A raised limit is read with `limit`, not spent here."""
301
+ at = utc_time(self._now(now))
302
+ with self._lock:
303
+ data = self._load()
304
+ kind = "usd" if cost_usd is not None else "jobs"
305
+ for lift in sorted(self._matching(data, reason, caller, None if job_id is None else str(job_id), at, kind),
306
+ key=lambda l: (l.get("job") is None, l.get("caller") is None)):
307
+ need = cost_usd if kind == "usd" else 1
308
+ if lift["left"] >= need:
309
+ lift["left"] = round(lift["left"] - need, 6) if kind == "usd" else lift["left"] - 1
310
+ self._save(data)
311
+ return {"admitted": True, "lift": lift["id"], "left": lift["left"]}
312
+ return {"admitted": False}
313
+
314
+ def limit(self, *, reason: str, base: float, caller: Optional[str] = None, now: Optional[str] = None) -> float:
315
+ """The limit in force: `base`, or the highest live raised limit for (reason, caller); `base` again once the
316
+ window ends."""
317
+ at = utc_time(self._now(now))
318
+ raised = [l["limit"] for l in self._matching(self._load(), reason, caller, None, at, "limit")]
319
+ return max([base] + raised)
320
+
321
+ def lifts(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
322
+ """Every lift with `live`: whether it still applies now."""
323
+ at = utc_time(self._now(now))
324
+ return [dict(l, live=self._live(l, at)) for l in self._load()["lifts"]]
@@ -73,8 +73,9 @@ class HttpError(Exception):
73
73
  self.retry_after_s = retry_after_s
74
74
  self.body = bytes(body[:2048])
75
75
  self.kind = kind # http (an answer), connection, timeout, too-large, refused, foreign-user
76
- #: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, which
77
- #: never clears), the owner's kind (`another-account`, `system`) or the check's reason (`no-row`).
76
+ #: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, or on
77
+ #: macOS no row for this connection, DEC-0155 RT-1; it never clears), the owner's kind (`another-account`,
78
+ #: `system`) or the check's reason (`no-row`, from Linux and Windows only).
78
79
  self.reason = reason
79
80
 
80
81
 
@@ -361,13 +362,43 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
361
362
  self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
362
363
 
363
364
 
365
+ def _peer_is_loopback(sock: Any) -> bool:
366
+ """Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
367
+ spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0`, `::ffff:127.0.0.1` and a proxy on loopback all
368
+ arrive here (0.74.0 unit readings 7, 8 and 11; FAA-260)."""
369
+ try:
370
+ host = str(sock.getpeername()[0]).split("%", 1)[0]
371
+ address = ipaddress.ip_address(host)
372
+ except (OSError, ValueError, TypeError, IndexError, AttributeError):
373
+ return False
374
+ mapped = getattr(address, "ipv4_mapped", None)
375
+ return bool((mapped or address).is_loopback or address.is_unspecified)
376
+
377
+
378
+ class _PlainHTTPConnection(http.client.HTTPConnection):
379
+ """Plain http to a host not spelled as loopback (`insecure=True`) or to a proxy from the environment: when the
380
+ connected socket's peer is a loopback address, its server end is told as `_CheckedHTTPConnection` tells it, and
381
+ another account's, root's or an unknown owner is refused before a byte is sent (DEC-0154 HA-2, FAA-260)."""
382
+
383
+ def connect(self) -> None:
384
+ super().connect()
385
+ if not _peer_is_loopback(self.sock):
386
+ return
387
+ owner = _po.server_end(self.sock)
388
+ port = self.sock.getpeername()[1]
389
+ refused = _po.refusal(owner, port, None)
390
+ if refused is not None:
391
+ self.close()
392
+ raise _po.ListenerRefused(refused[0], "%s; nothing was sent" % refused[1], owner)
393
+
394
+
364
395
  def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
365
396
  """`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
366
397
  owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
367
398
  class Http(urllib.request.HTTPHandler):
368
399
  def http_open(self, req):
369
400
  local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
370
- return self.do_open(_watched(_CheckedHTTPConnection if local else http.client.HTTPConnection, watchdog), req)
401
+ return self.do_open(_watched(_CheckedHTTPConnection if local else _PlainHTTPConnection, watchdog), req)
371
402
 
372
403
  class Https(urllib.request.HTTPSHandler):
373
404
  def https_open(self, req):
@@ -70,8 +70,9 @@ class HubError(Exception):
70
70
  def listener_refusal(exc: Any) -> HubError:
71
71
  """The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
72
72
  contract's state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted`
73
- when this process's socket table hides other processes' sockets, which never clears, so it is not retryable;
74
- otherwise the owner's kind or the check's reason (`another-account`, `no-row`), retryable as before."""
73
+ when this process's socket table hides other processes' sockets, or, on macOS, holds no row for this connection
74
+ (DEC-0155 RT-1, FAA-312), which never clears, so it is not retryable; otherwise the owner's kind or the check's
75
+ reason (`another-account`, or `no-row`, which only Linux and Windows give), retryable."""
75
76
  owner = getattr(exc, "owner", None)
76
77
  why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
77
78
  reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
@@ -94,25 +95,37 @@ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = No
94
95
  return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
95
96
 
96
97
 
97
- def pid_alive(pid: int) -> bool:
98
- """Whether a process with this id runs as this user (Fabric `hub.ts#pidAlive`); never signals it."""
98
+ def _windows_pid_alive(pid: int, kernel: Any = None, last_error: Any = None) -> Optional[bool]:
99
+ """pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: OpenProcess with query access only. Refused
100
+ with ERROR_ACCESS_DENIED (5), another account's or an elevated process: None, as Node's process.kill(pid, 0)
101
+ answers EPERM there (FAA-304); refused otherwise, no process. `kernel` and `last_error` stand in for kernel32 and
102
+ ctypes.get_last_error in a test."""
103
+ import ctypes
104
+ if kernel is None:
105
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
106
+ last_error = ctypes.get_last_error # type: ignore[attr-defined]
107
+ handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
108
+ if not handle:
109
+ return None if last_error() == 5 else False # ERROR_ACCESS_DENIED: not verified, never called running
110
+ try:
111
+ code = ctypes.c_ulong()
112
+ return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
113
+ finally:
114
+ kernel.CloseHandle(handle)
115
+
116
+
117
+ def pid_alive(pid: int) -> Optional[bool]:
118
+ """True: a process of this OS account runs under `pid`; False: none does. None (falsy): a process of another account
119
+ does (EPERM; on Windows, OpenProcess refused with access denied), as when the id hub.json names was reused: not
120
+ verified, never called running (FAA-304, the session hooks' answer since FAA-254). Never signals it."""
99
121
  if pid <= 0:
100
122
  return False
101
123
  if sys.platform == "win32":
102
- import ctypes
103
- kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
104
- handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
105
- if not handle:
106
- return False
107
- try:
108
- code = ctypes.c_ulong()
109
- return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
110
- finally:
111
- kernel.CloseHandle(handle)
124
+ return _windows_pid_alive(pid)
112
125
  try:
113
126
  os.kill(pid, 0)
114
127
  except PermissionError:
115
- return False # another user's process under a reused id: not this user's Fabric
128
+ return None # another account's process under a reused id: not verified, and not this account's Fabric
116
129
  except OSError:
117
130
  return False
118
131
  return True
@@ -142,7 +155,8 @@ def _read_small(path: Path) -> Optional[str]:
142
155
  def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_alive, estate: Optional[Dict[str, Any]] = None,
143
156
  now: Optional[float] = None) -> Dict[str, Any]:
144
157
  """`hub.json` read the way an agent must, every call. Answers `{origin, mcpUrl, doorTokenFile, pid, startedAt,
145
- features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`). Reads no token. `estate` is
158
+ features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`, retryable when hub.json names a process of
159
+ another OS account, which is not verified, FAA-304). Reads no token. `estate` is
146
160
  `{"record": <estate record>, "applied": {"revision", "digest"?} | None}`: with it, a `hubKey` the document names is
147
161
  checked by `hub_key_certified` (DEC-0060); fabric-hub/0.1 names none, and then nothing is checked."""
148
162
  root = Path(root) if root is not None else fabric_root()
@@ -174,7 +188,11 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
174
188
  token_file = doc.get("doorTokenFile") if isinstance(doc.get("doorTokenFile"), str) else ""
175
189
  if not os.path.isabs(token_file) or os.path.realpath(os.path.dirname(token_file)) != os.path.realpath(str(root)):
176
190
  raise not_hub("names a door token outside the registry root")
177
- if not alive(pid):
191
+ live = alive(pid)
192
+ if live is None: # FAA-304: another account's process under that id, so not this account's Fabric (as FAA-254)
193
+ raise HubError("not-a-hub", "Fabric's hub.json names process %d, which runs under another OS account: not "
194
+ "verified." % pid, True)
195
+ if not live:
178
196
  raise HubError("not-running", "Fabric is not running: hub.json names process %d, which is not running." % pid, True)
179
197
  features = doc.get("features")
180
198
  features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
@@ -1,5 +1,15 @@
1
1
  """One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock, the supervisor marker
2
- in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3)."""
2
+ in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
3
+
4
+ A lock that moves under its holder (FAA-303). Linux and Windows hold a name keyed by the lock's path beside the file
5
+ lock (an abstract unix socket, a named pipe), so a folder swapped under a running copy cannot let a second copy in.
6
+ macOS has no such name: flock (and the Node twin's O_EXLOCK) lock an inode, so after a data import swaps the data
7
+ folder a second copy locks the new file and starts. There the holder watches its lock instead
8
+ (``InstanceLock.watch``, started by ``hold_single_instance``): every ``LOCK_WATCH_SECONDS`` it compares the inode it
9
+ locked with the file at the path, and once the path names another file it stops with 75 and one sentence naming the
10
+ path, before writing anything more into a folder that is no longer the one it read. Two copies then overlap for at
11
+ most one interval. A path-keyed name on macOS was weighed and left: it needs a file in a folder outside the data folder
12
+ that the system never cleans and every copy can write, which the standard library cannot promise."""
3
13
  from __future__ import annotations
4
14
 
5
15
  import errno
@@ -10,6 +20,7 @@ import posixpath
10
20
  import re
11
21
  import socket
12
22
  import sys
23
+ import threading
13
24
  import time
14
25
  from pathlib import Path
15
26
  from typing import Any, Callable, Dict, List, Optional, Sequence
@@ -46,6 +57,11 @@ LOCK_BACKOFF_FIRST_SECONDS = 0.5
46
57
  LOCK_BACKOFF_MAX_SECONDS = 30.0
47
58
 
48
59
 
60
+ #: How often a holder that has no path-keyed name (macOS) compares the inode it locked with the file at the lock's
61
+ #: path (FAA-303): one stat and one fstat; two copies overlap for at most this long after a folder swap.
62
+ LOCK_WATCH_SECONDS = 2.0
63
+
64
+
49
65
  class AlreadyRunning(ServiceError):
50
66
  def __init__(self, holder_pid: Optional[int], lock_path: Path):
51
67
  self.holder_pid = holder_pid
@@ -67,6 +83,7 @@ class InstanceLock:
67
83
  self.path = Path(data_dir) / "service.lock"
68
84
  self._fd: Optional[int] = None
69
85
  self._name: Any = None
86
+ self._watch: Optional[threading.Event] = None
70
87
 
71
88
  #: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
72
89
  #: holder may rewrite the pid and another process may still read it (locking past the end of
@@ -157,7 +174,41 @@ class InstanceLock:
157
174
  self._fd = fd
158
175
  return True
159
176
 
177
+ def watch(self, on_lost: Optional[Callable[["InstanceLock"], None]] = None,
178
+ interval: float = LOCK_WATCH_SECONDS) -> "InstanceLock":
179
+ """Re-check, every ``interval`` seconds on a daemon thread, that the file this copy locked is still the file at
180
+ the path (FAA-303); the first time the path names another file (a swapped folder's lock, or the one a second
181
+ copy made), call ``on_lost(lock)`` once and stop watching. The default stops the process with 75 and one
182
+ sentence (``stop_lost_holder``): it runs on the watch thread, so it cannot raise into the service. A path that
183
+ is gone or cannot be read now is read as nothing there yet and tried again next time: a second copy has to make
184
+ a file there to start, and the watch then sees that file. ``release`` ends the watch. Windows does not rename a
185
+ folder holding an open file: there it is a no-op, as for a lock not held."""
186
+ if WINDOWS or self._fd is None or self._watch is not None:
187
+ return self
188
+ fd, stop, react = self._fd, threading.Event(), on_lost or stop_lost_holder
189
+
190
+ def run() -> None:
191
+ while not stop.wait(interval):
192
+ try:
193
+ here, held = os.stat(str(self.path)), os.fstat(fd)
194
+ except OSError:
195
+ continue
196
+ # release() sets the event before it closes the descriptor, so a number reused after the close is never
197
+ # read as this lock's file: the event is checked after the stat.
198
+ if (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino) or stop.is_set():
199
+ continue
200
+ stop.set()
201
+ react(self)
202
+ return
203
+
204
+ self._watch = stop
205
+ threading.Thread(target=run, name="fabric-lock-watch", daemon=True).start()
206
+ return self
207
+
160
208
  def release(self) -> None:
209
+ if self._watch is not None:
210
+ self._watch.set()
211
+ self._watch = None
161
212
  if self._fd is not None:
162
213
  os.close(self._fd)
163
214
  self._fd = None
@@ -166,6 +217,22 @@ class InstanceLock:
166
217
  self._name = None
167
218
 
168
219
 
220
+ def lost_lock_message(lock_path: Path) -> str:
221
+ """The one sentence a holder whose lock moved under it stops with (FAA-303)."""
222
+ return ("%s is no longer the file this copy locked (the data folder moved or was swapped?): stopping, so a copy "
223
+ "started on the folder now at that path runs alone." % lock_path)
224
+
225
+
226
+ def stop_lost_holder(lock: InstanceLock) -> None:
227
+ """``InstanceLock.watch``'s default: print ``lost_lock_message`` and end the process with 75 at once. ``os._exit``,
228
+ not a graceful stop: a shutdown would write this copy's state into the folder now at the path, which is not the
229
+ one it read; the kit's journals make an abrupt stop recoverable (LC-03)."""
230
+ try:
231
+ print(lost_lock_message(lock.path), file=sys.stderr, flush=True)
232
+ finally:
233
+ os._exit(EXIT_ALREADY_RUNNING)
234
+
235
+
169
236
  def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
170
237
  """The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
171
238
  if platform.startswith("linux"):
@@ -329,7 +396,8 @@ def add_supervisor_argument(parser: Any) -> Any:
329
396
  def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
330
397
  sleep: Callable[[float], None] = time.sleep,
331
398
  clock: Callable[[], float] = time.monotonic,
332
- env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
399
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
400
+ on_lost: Optional[Callable[[InstanceLock], None]] = None) -> InstanceLock:
333
401
  """Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
334
402
 
335
403
  Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
@@ -337,7 +405,10 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
337
405
  exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
338
406
  (0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
339
407
  leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
340
- ``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
408
+ ``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
409
+
410
+ Where the lock has no path-keyed name (macOS) the held lock is watched (``InstanceLock.watch``, FAA-303): a folder
411
+ swapped under the running copy stops it with 75; ``on_lost`` replaces that reaction."""
341
412
  marker = supervisor_marker(env, argv)
342
413
  if marker["kind"] == "conflict":
343
414
  print(marker_conflict_message(marker), file=sys.stderr)
@@ -349,7 +420,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
349
420
  announced = False
350
421
  while True:
351
422
  try:
352
- return InstanceLock(data_dir).acquire()
423
+ lock = InstanceLock(data_dir).acquire()
353
424
  except AlreadyRunning as exc:
354
425
  remaining = deadline - clock()
355
426
  if remaining <= 0:
@@ -360,3 +431,5 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
360
431
  announced = True
361
432
  sleep(min(delay, remaining))
362
433
  delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
434
+ continue
435
+ return lock.watch(on_lost) if lock_name(lock.path) is None else lock