dsh-live-teams 0.1.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 (69) hide show
  1. package/LICENSE +176 -0
  2. package/NOTICE +11 -0
  3. package/README.md +85 -0
  4. package/cordis.patch.yml +25 -0
  5. package/lib/binding.d.ts +18 -0
  6. package/lib/binding.js +42 -0
  7. package/lib/changed-paths.d.ts +26 -0
  8. package/lib/changed-paths.js +69 -0
  9. package/lib/client.js +6753 -0
  10. package/lib/command-queue.d.ts +60 -0
  11. package/lib/command-queue.js +185 -0
  12. package/lib/compatibility.js +109 -0
  13. package/lib/context-provider.d.ts +110 -0
  14. package/lib/context-provider.js +249 -0
  15. package/lib/dispatch.d.ts +174 -0
  16. package/lib/dispatch.js +624 -0
  17. package/lib/errors.d.ts +36 -0
  18. package/lib/errors.js +103 -0
  19. package/lib/git-artifacts.d.ts +50 -0
  20. package/lib/git-artifacts.js +242 -0
  21. package/lib/index.d.ts +14 -0
  22. package/lib/index.js +14 -0
  23. package/lib/mailbox.d.ts +274 -0
  24. package/lib/mailbox.js +721 -0
  25. package/lib/member-tools.d.ts +57 -0
  26. package/lib/member-tools.js +1265 -0
  27. package/lib/migrations.d.ts +17 -0
  28. package/lib/migrations.js +47 -0
  29. package/lib/plugin.d.ts +106 -0
  30. package/lib/plugin.js +1003 -0
  31. package/lib/roles.d.ts +35 -0
  32. package/lib/roles.js +284 -0
  33. package/lib/routes.d.ts +586 -0
  34. package/lib/routes.js +2816 -0
  35. package/lib/scope.d.ts +62 -0
  36. package/lib/scope.js +133 -0
  37. package/lib/session-bridge.d.ts +76 -0
  38. package/lib/session-bridge.js +147 -0
  39. package/lib/session-title.js +35 -0
  40. package/lib/storage.d.ts +9 -0
  41. package/lib/storage.js +65 -0
  42. package/lib/task-store.d.ts +729 -0
  43. package/lib/task-store.js +2205 -0
  44. package/lib/team-store.d.ts +216 -0
  45. package/lib/team-store.js +765 -0
  46. package/lib/tree-snapshot.d.ts +28 -0
  47. package/lib/tree-snapshot.js +80 -0
  48. package/lib/types/client/TeamView.d.ts +26 -0
  49. package/lib/types/client/TeamView.dom.test.d.ts +1 -0
  50. package/lib/types/client/api.d.ts +522 -0
  51. package/lib/types/client/api.test.d.ts +1 -0
  52. package/lib/types/client/attention.d.ts +65 -0
  53. package/lib/types/client/attention.test.d.ts +1 -0
  54. package/lib/types/client/index.d.ts +31 -0
  55. package/lib/types/client/locales.d.ts +577 -0
  56. package/lib/types/client/member-name.d.ts +14 -0
  57. package/lib/types/client/member-name.test.d.ts +1 -0
  58. package/lib/types/client/roster.d.ts +26 -0
  59. package/lib/types/client/roster.test.d.ts +1 -0
  60. package/lib/types/client/styles.d.ts +3 -0
  61. package/package.json +104 -0
  62. package/roles/builder.md +40 -0
  63. package/roles/delegate.md +36 -0
  64. package/roles/lead.md +46 -0
  65. package/roles/oracle.md +36 -0
  66. package/roles/researcher.md +37 -0
  67. package/roles/reviewer.md +45 -0
  68. package/roles/scout.md +36 -0
  69. package/roles/verifier.md +36 -0
@@ -0,0 +1,624 @@
1
+ import { LiveTeamsError } from "./errors.js";
2
+ import { readDispatchSwitch } from "./team-store.js";
3
+ import "./mailbox.js";
4
+ import { CODING_KINDS, SHARED_PATH_DEFAULTS, scopeConflict } from "./scope.js";
5
+ import { assignmentRouteText, blockedBy, taskDocumentPath } from "./task-store.js";
6
+ //#region src/dispatch.ts
7
+ /**
8
+ * The dispatcher is a reconciler, not an event handler: callers pass it wake-up
9
+ * signals, and every pass recomputes its decisions from durable state. A missed
10
+ * signal therefore cannot lose work, and a duplicated one cannot duplicate it.
11
+ *
12
+ * It has exactly two actions — retry a delivery that never reached its member, and
13
+ * start an assigned task whose gate is open — and it never chooses a member, never
14
+ * moves ownership, never edits a contract and never escalates.
15
+ */
16
+ const DISPATCH_REASONS = Object.freeze([
17
+ "dispatch-paused",
18
+ "attempt-revoking",
19
+ "human-gate",
20
+ "task-not-ready",
21
+ "no-assignee",
22
+ "dependency-open",
23
+ "coding-not-isolated",
24
+ "human-intervened",
25
+ "task-settled-during-pass",
26
+ "paths-undeclared",
27
+ "paths-overlap",
28
+ "paths-shared",
29
+ "attempt-active",
30
+ "member-unknown",
31
+ "member-paused",
32
+ "member-busy",
33
+ "no-session",
34
+ "ok"
35
+ ]);
36
+ /** The verdict follows from the reason, so the two can never disagree. */
37
+ const VERDICTS = {
38
+ "ok": "assign",
39
+ "attempt-active": "active",
40
+ "dispatch-paused": "wait",
41
+ "attempt-revoking": "wait",
42
+ "human-gate": "wait",
43
+ "dependency-open": "wait",
44
+ "coding-not-isolated": "wait",
45
+ "human-intervened": "wait",
46
+ "task-settled-during-pass": "wait",
47
+ "paths-undeclared": "wait",
48
+ "paths-overlap": "wait",
49
+ "paths-shared": "wait",
50
+ "member-paused": "wait",
51
+ "member-busy": "wait",
52
+ "no-session": "wait",
53
+ "task-not-ready": "refused",
54
+ "no-assignee": "refused",
55
+ "member-unknown": "refused"
56
+ };
57
+ const TERMINAL = [
58
+ "completed",
59
+ "failed",
60
+ "cancelled"
61
+ ];
62
+ /** The assignment message for one attempt; also its dedupe key across passes. */
63
+ function assignmentThread(taskId, attemptId) {
64
+ return `dispatch:${taskId}:${attemptId}`;
65
+ }
66
+ /** The latest attempt of a task that carries a mark the contract has not caught up with. */
67
+ function unresolvedIntervention(task, attempts) {
68
+ const latest = attempts.filter((attempt) => attempt.taskId === task.id).sort((left, right) => right.generation - left.generation)[0];
69
+ if (latest?.humanIntervenedAt === void 0) return void 0;
70
+ return (task.contractRevision ?? 0) <= (latest.humanIntervenedContractRevision ?? 0) ? latest : void 0;
71
+ }
72
+ function activeAttemptOf(task, attempts) {
73
+ return task.activeAttemptId === void 0 ? void 0 : attempts.find((attempt) => attempt.id === task.activeAttemptId);
74
+ }
75
+ function memberName(team, memberId) {
76
+ return team.members.find((member) => member.memberId === memberId)?.displayName ?? memberId;
77
+ }
78
+ /**
79
+ * The gate. The first blocker wins and its name *is* what the human reads, so the
80
+ * states that need a person are reported as what they are before the generic ones.
81
+ */
82
+ function evaluateTask(input) {
83
+ const { task, tasks, attempts, team } = input;
84
+ const assignee = task.assignee;
85
+ const base = {
86
+ taskId: task.id,
87
+ title: task.title,
88
+ status: task.status,
89
+ ...assignee === void 0 ? {} : { assignee }
90
+ };
91
+ const verdict = (reason, detail, warning) => ({
92
+ ...base,
93
+ decision: VERDICTS[reason],
94
+ reason,
95
+ detail,
96
+ ...warning === void 0 ? {} : { warning }
97
+ });
98
+ const attempt = activeAttemptOf(task, attempts);
99
+ if (input.paused) return verdict("dispatch-paused", "automatic dispatch is paused");
100
+ if (attempt?.status === "revoking") return verdict("attempt-revoking", `attempt ${attempt.id} is revoking — settle the revocation before anything starts beside it`);
101
+ const own = unresolvedIntervention(task, attempts);
102
+ if (own !== void 0) return verdict("human-intervened", `a human wrote into ${memberName(team, own.memberId)}'s Session directly (attempt ${own.id}); record the change in the contract, or accept/revise the task, before the dispatcher continues`);
103
+ if (task.status === "waiting") return verdict("human-gate", "the member is waiting for an answer");
104
+ if (task.status === "needs_human") return verdict("human-gate", "the task needs a human decision");
105
+ if (attempt?.status === "active") {
106
+ const active = verdict("attempt-active", `attempt ${attempt.id} is active with ${memberName(team, attempt.memberId)}`);
107
+ return attempt.stalled === void 0 ? active : {
108
+ ...active,
109
+ stalled: attempt.stalled
110
+ };
111
+ }
112
+ if (task.status !== "ready") return verdict("task-not-ready", TERMINAL.includes(task.status) ? `task is ${task.status}` : `task is ${task.status}, not ready`);
113
+ if (assignee === void 0 || assignee.length === 0) return verdict("no-assignee", "the task has no assignee");
114
+ const blockers = blockedBy(task, input.universe ?? tasks);
115
+ if (blockers.length > 0) {
116
+ const first = blockers[0];
117
+ return verdict("dependency-open", `blocked by ${first.id} "${first.title}" (${first.status})${blockers.length > 1 ? ` and ${blockers.length - 1} more` : ""}`);
118
+ }
119
+ if (input.coding === "manual" && CODING_KINDS.has(task.kind)) return verdict("coding-not-isolated", `${task.kind} work is not started automatically: this deployment has not accepted coding dispatch without per-member isolation, and this host cannot provide it (ADR 0008 — set codingDispatch: automatic to accept the shared tree)`);
120
+ const intervenedDependency = (task.dependsOn ?? []).map((id) => tasks.find((entry) => entry.id === id)).find((dependency) => dependency !== void 0 && dependency.status === "completed" && unresolvedIntervention(dependency, attempts) !== void 0);
121
+ if (intervenedDependency !== void 0) return verdict("human-intervened", `dependency ${intervenedDependency.id} "${intervenedDependency.title}" was completed after a human changed its work directly, and the change was never recorded`);
122
+ const member = input.memberOf(assignee);
123
+ if (member === void 0) return verdict("member-unknown", "the assignee is not a current team member");
124
+ if (member.availability !== "available") return verdict("member-paused", `${member.displayName} is ${member.availability === "paused" ? "paused" : `unavailable (${String(member.availability)})`}`);
125
+ const busy = attempts.find((candidate) => candidate.status === "active" && candidate.memberId === assignee && candidate.taskId !== task.id);
126
+ if (busy !== void 0) return verdict("member-busy", `${member.displayName} already holds attempt ${busy.id} on ${busy.taskId}`);
127
+ if (member.sessionId === void 0 || member.sessionId.length === 0) return verdict("no-session", `${member.displayName} has no Session bound`);
128
+ if (CODING_KINDS.has(task.kind)) {
129
+ const writers = attempts.filter((candidate) => candidate.status === "active" && candidate.taskId !== task.id).map((candidate) => ({
130
+ attempt: candidate,
131
+ other: (input.universe ?? tasks).find((entry) => entry.id === candidate.taskId)
132
+ })).filter((entry) => entry.other !== void 0 && CODING_KINDS.has(entry.other.kind));
133
+ if (writers.length > 0) {
134
+ const shared = [...SHARED_PATH_DEFAULTS, ...input.sharedPaths ?? []];
135
+ const own = task.paths ?? [];
136
+ if (own.length === 0) {
137
+ const active = writers[0];
138
+ return verdict("paths-undeclared", `another writing task is active (${active.other.id} "${active.other.title}"), and this task declares no paths — declare them so the two can be shown to be disjoint`);
139
+ }
140
+ for (const writer of writers) {
141
+ const otherPaths = writer.other?.paths ?? [];
142
+ if (otherPaths.length === 0) return verdict("paths-overlap", `${writer.other?.id} "${writer.other?.title}" is writing and declares no paths, so its scope is unknown`);
143
+ const conflict = scopeConflict(own, otherPaths, shared);
144
+ if (conflict === void 0) continue;
145
+ const pair = `${conflict.left} × ${conflict.right}`;
146
+ if (conflict.kind === "shared") return verdict("paths-shared", `${pair} is shared by every task, and ${writer.other?.id} "${writer.other?.title}" is writing — serialise the two with dependsOn, or hand one over deliberately`);
147
+ return verdict("paths-overlap", `${pair} intersects ${writer.other?.id} "${writer.other?.title}", which is writing now`);
148
+ }
149
+ }
150
+ }
151
+ const preferred = input.preferredKindsOf?.(assignee) ?? [];
152
+ const warning = preferred.length > 0 && !preferred.includes(task.kind) ? `${member.displayName}'s role prefers ${preferred.join(", ")}, and this task is ${task.kind}` : void 0;
153
+ return verdict("ok", `eligible to start as attempt for ${member.displayName}`, warning);
154
+ }
155
+ function projectDispatch(input) {
156
+ const evaluations = input.tasks.map((task) => evaluateTask({
157
+ ...input,
158
+ task,
159
+ paused: input.dispatch.mode === "paused",
160
+ coding: input.dispatch.coding ?? "manual"
161
+ })).sort((left, right) => left.taskId.localeCompare(right.taskId));
162
+ const counts = {
163
+ assign: 0,
164
+ active: 0,
165
+ wait: 0,
166
+ refused: 0
167
+ };
168
+ for (const evaluation of evaluations) counts[evaluation.decision] += 1;
169
+ const paused = input.dispatch.mode === "paused";
170
+ return {
171
+ paused,
172
+ coding: input.dispatch.coding ?? "manual",
173
+ ...paused && input.dispatch.by !== void 0 ? { pausedBy: input.dispatch.by } : {},
174
+ ...paused && input.dispatch.reason !== void 0 ? { pausedReason: input.dispatch.reason } : {},
175
+ evaluations,
176
+ counts
177
+ };
178
+ }
179
+ function assignmentText(task, attempt, team, workspacePath) {
180
+ const route = assignmentRouteText(task.route, team);
181
+ const contact = task.humanContact === "required" ? "required before finishing" : task.humanContact === "expected" ? "expected — the lead will arrange it" : "none expected";
182
+ return [
183
+ `You have been assigned task ${task.id}: ${task.title} (${task.kind}).`,
184
+ `A claim was made for you as attempt ${attempt.id}.`,
185
+ `- Route: ${route}`,
186
+ `- Round limit: ${task.roundLimit}`,
187
+ `- Human contact: ${contact}`,
188
+ `- Document: ${taskDocumentPath(workspacePath, task.teamId, task.id)}`,
189
+ `Read it with live_team_read_task, then acknowledge it with live_team_ack_task before you start work.`,
190
+ "Report a blocker with live_team_send to your lead rather than working around it."
191
+ ].join("\n");
192
+ }
193
+ const TRIGGER_KINDS = /^(task|attempt|submission|review|member|team|delivery)\//;
194
+ function createDispatcher(ports) {
195
+ const logger = ports.logger ?? (() => void 0);
196
+ const now = ports.now ?? Date.now;
197
+ const maxActions = ports.maxActionsPerPass ?? 8;
198
+ const sweepIntervalMs = ports.sweepIntervalMs ?? 3e4;
199
+ let warnedUnobservable = false;
200
+ let running;
201
+ let rerun = false;
202
+ let timer;
203
+ let stopped = false;
204
+ /** Last reason seen per task, so a journal entry marks a transition, not a pass. */
205
+ const lastReason = /* @__PURE__ */ new Map();
206
+ async function wakeHalf(result) {
207
+ const expired = await ports.mailbox.retryExpiredLeases();
208
+ for (const delivery of expired) {
209
+ if (delivery.state !== "delivered") continue;
210
+ result.woke.push({
211
+ deliveryId: delivery.id,
212
+ messageId: delivery.messageId,
213
+ state: delivery.state
214
+ });
215
+ await ports.appendAudit({
216
+ kind: "dispatch/woke",
217
+ teamId: ports.teamId,
218
+ deliveryId: delivery.id,
219
+ messageId: delivery.messageId,
220
+ state: delivery.state,
221
+ at: now()
222
+ });
223
+ }
224
+ const state = await ports.readTeam();
225
+ for (const delivery of await ports.mailbox.listDeliveries()) {
226
+ if (delivery.state !== "stored" || delivery.wakePolicy !== "queue") continue;
227
+ try {
228
+ const woken = await ports.mailbox.deliver(delivery.id);
229
+ result.woke.push({
230
+ deliveryId: woken.id,
231
+ messageId: woken.messageId,
232
+ state: woken.state
233
+ });
234
+ await ports.appendAudit({
235
+ kind: "dispatch/woke",
236
+ teamId: ports.teamId,
237
+ deliveryId: woken.id,
238
+ messageId: woken.messageId,
239
+ state: woken.state,
240
+ at: now()
241
+ });
242
+ } catch (error) {
243
+ logger(`live-teams: dispatch could not deliver stored ${delivery.id}: ${error instanceof Error ? error.message : String(error)}`);
244
+ }
245
+ }
246
+ for (const delivery of await ports.mailbox.listDeliveries()) {
247
+ if (delivery.state !== "failed" || delivery.failure !== "recipient has no current session binding") continue;
248
+ const member = state.members.find((candidate) => candidate.memberId === delivery.recipient);
249
+ if (member?.sessionId === void 0 || member.bindingGeneration === void 0) continue;
250
+ try {
251
+ const retried = await ports.mailbox.retryDelivery(delivery.id);
252
+ if (retried.state !== "delivered") continue;
253
+ result.woke.push({
254
+ deliveryId: retried.id,
255
+ messageId: retried.messageId,
256
+ state: retried.state
257
+ });
258
+ await ports.appendAudit({
259
+ kind: "dispatch/woke",
260
+ teamId: ports.teamId,
261
+ deliveryId: retried.id,
262
+ messageId: retried.messageId,
263
+ state: retried.state,
264
+ at: now()
265
+ });
266
+ } catch (error) {
267
+ logger(`live-teams: dispatch could not retry delivery ${delivery.id}: ${error instanceof Error ? error.message : String(error)}`);
268
+ }
269
+ }
270
+ }
271
+ async function startHalf(state, result) {
272
+ const universe = await ports.taskStore.listTasks({ includeArchived: true });
273
+ const tasks = universe.filter((task) => task.archivedPath === void 0);
274
+ result.evaluated = tasks.length;
275
+ const attempts = (await ports.taskStore.readAttempts()).attempts;
276
+ const messages = await ports.mailbox.listMessages();
277
+ const deliveries = await ports.mailbox.listDeliveries();
278
+ const memberOf = (memberId) => state.members.find((member) => member.memberId === memberId);
279
+ let actions = 0;
280
+ for (const task of [...tasks].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id))) {
281
+ const evaluation = evaluateTask({
282
+ task,
283
+ tasks,
284
+ universe,
285
+ attempts,
286
+ team: state,
287
+ paused: false,
288
+ coding: ports.codingDispatch ?? "manual",
289
+ ...ports.sharedPaths === void 0 ? {} : { sharedPaths: ports.sharedPaths },
290
+ memberOf,
291
+ ...ports.preferredKindsOf === void 0 ? {} : { preferredKindsOf: ports.preferredKindsOf }
292
+ });
293
+ const previous = lastReason.get(task.id);
294
+ lastReason.set(task.id, evaluation.reason);
295
+ const attempt = task.activeAttemptId === void 0 ? void 0 : attempts.find((candidate) => candidate.id === task.activeAttemptId);
296
+ const repair = evaluation.decision === "active" && attempt !== void 0 && !messages.some((candidate) => candidate.threadId === assignmentThread(task.id, attempt.id));
297
+ if (evaluation.decision !== "assign" && !repair) {
298
+ result.skipped.push({
299
+ taskId: task.id,
300
+ reason: evaluation.reason,
301
+ detail: evaluation.detail
302
+ });
303
+ if (previous !== void 0 && previous !== evaluation.reason && evaluation.reason !== "attempt-active") {
304
+ result.blocked.push({
305
+ taskId: task.id,
306
+ reason: evaluation.reason,
307
+ detail: evaluation.detail
308
+ });
309
+ await ports.appendAudit({
310
+ kind: "dispatch/blocked",
311
+ teamId: ports.teamId,
312
+ taskId: task.id,
313
+ reason: evaluation.reason,
314
+ detail: evaluation.detail,
315
+ from: previous,
316
+ at: now()
317
+ });
318
+ }
319
+ continue;
320
+ }
321
+ if (actions >= maxActions) {
322
+ result.skipped.push({
323
+ taskId: task.id,
324
+ reason: evaluation.reason,
325
+ detail: "per-pass action limit reached"
326
+ });
327
+ continue;
328
+ }
329
+ actions += 1;
330
+ const member = memberOf(task.assignee);
331
+ try {
332
+ const claimed = await ports.taskStore.claimTask({
333
+ taskId: task.id,
334
+ memberId: member.memberId,
335
+ sessionId: member.sessionId,
336
+ bindingGeneration: member.bindingGeneration
337
+ });
338
+ if (!attempts.some((candidate) => candidate.id === claimed.attempt.id)) attempts.push(claimed.attempt);
339
+ const settled = (await ports.taskStore.listTasks({ includeArchived: true })).find((candidate) => candidate.id === task.id);
340
+ if (settled === void 0 || TERMINAL.includes(settled.status)) {
341
+ result.skipped.push({
342
+ taskId: task.id,
343
+ reason: "task-settled-during-pass",
344
+ detail: `the task became ${settled?.status ?? "unknown"} while this pass was running; no assignment was sent`
345
+ });
346
+ continue;
347
+ }
348
+ const threadId = assignmentThread(task.id, claimed.attempt.id);
349
+ const existing = messages.find((message) => message.threadId === threadId);
350
+ if (existing !== void 0) {
351
+ for (const delivery of deliveries.filter((candidate) => candidate.messageId === existing.id)) {
352
+ if (delivery.state === "delivered" || delivery.state === "acknowledged") continue;
353
+ try {
354
+ await ports.mailbox.deliver(delivery.id);
355
+ } catch (error) {
356
+ logger(`live-teams: dispatch could not deliver ${delivery.id}: ${error instanceof Error ? error.message : String(error)}`);
357
+ }
358
+ }
359
+ result.started.push({
360
+ taskId: task.id,
361
+ attemptId: claimed.attempt.id,
362
+ memberId: member.memberId,
363
+ messageId: existing.id,
364
+ detail: "already dispatched"
365
+ });
366
+ continue;
367
+ }
368
+ const sent = await ports.mailbox.sendFromScheduler({
369
+ to: member.memberId,
370
+ kind: "message",
371
+ content: assignmentText(task, claimed.attempt, state, ports.workspacePath),
372
+ threadId
373
+ });
374
+ const messageId = sent.message.id;
375
+ messages.push(sent.message);
376
+ result.started.push({
377
+ taskId: task.id,
378
+ attemptId: claimed.attempt.id,
379
+ memberId: member.memberId,
380
+ messageId,
381
+ detail: repair ? `repaired a lost assignment for attempt ${claimed.attempt.id}` : evaluation.detail
382
+ });
383
+ await ports.appendAudit({
384
+ kind: "dispatch/assigned",
385
+ teamId: ports.teamId,
386
+ taskId: task.id,
387
+ attemptId: claimed.attempt.id,
388
+ memberId: member.memberId,
389
+ messageId,
390
+ reason: evaluation.reason,
391
+ detail: evaluation.detail,
392
+ ...repair ? { repair: true } : {},
393
+ at: now()
394
+ });
395
+ } catch (error) {
396
+ const message = error instanceof LiveTeamsError ? error.message : error instanceof Error ? error.message : String(error);
397
+ result.failures.push(`${task.id}: ${message}`);
398
+ logger(`live-teams: dispatch could not start ${task.id}: ${message}`);
399
+ }
400
+ }
401
+ }
402
+ /**
403
+ * Mark attempts a human wrote into mid-flight (B31). Only active attempts are
404
+ /**
405
+ * Close attempts left active on a task that has already ended (F5 of the first live
406
+ * run). The store writes the audit; the pass only reports what it healed, because a
407
+ * stale active attempt makes its member look busy for every later task.
408
+ */
409
+ /**
410
+ * Show an attempt that is not moving (F3 of the live pilot).
411
+ *
412
+ * Detection only: nothing is revoked and nothing is reassigned, because silence is not death — a
413
+ * member may be thinking, waiting for a person, or finished with a task it has not submitted. What
414
+ * the product owes the human is that silence is *visible*, with the two signals that mean it:
415
+ *
416
+ * - the assignment was never acknowledged (the task is still `claimed` long after the claim);
417
+ * - the work was acknowledged but nothing came of it: still no submission, and the Session is
418
+ * idle rather than running.
419
+ *
420
+ * A submission waiting for a reviewer, a lead or a person is deliberately *not* a stall: the ball
421
+ * is with somebody, and the projection already says who.
422
+ */
423
+ async function observeStalledAttempts(state, result) {
424
+ const threshold = ports.stalledAfterMs ?? 6e5;
425
+ const now = ports.now?.() ?? Date.now();
426
+ const tasks = await ports.taskStore.listTasks({ includeArchived: true });
427
+ const attempts = (await ports.taskStore.readAttempts()).attempts;
428
+ for (const attempt of attempts) {
429
+ if (attempt.status !== "active" || attempt.stalled !== void 0) continue;
430
+ const task = tasks.find((candidate) => candidate.id === attempt.taskId);
431
+ if (task === void 0 || task.latestSubmissionId !== void 0) continue;
432
+ const silence = now - (task.status === "claimed" ? attempt.startedAt : Math.max(attempt.startedAt, task.updatedAt));
433
+ if (silence < threshold) continue;
434
+ let reason = "unacknowledged";
435
+ let detail = `${memberName(state, attempt.memberId)} has not acknowledged the assignment in ${Math.round(silence / 6e4)} minutes`;
436
+ if (task.status !== "claimed") {
437
+ if (ports.sessionStatus === void 0) continue;
438
+ if (await ports.sessionStatus(attempt.sessionId).catch(() => "unknown") !== "idle") continue;
439
+ reason = "idle";
440
+ detail = `${memberName(state, attempt.memberId)} acknowledged the work ${Math.round(silence / 6e4)} minutes ago, its Session is idle and nothing has been submitted`;
441
+ }
442
+ if (await ports.taskStore.markAttemptStalled({
443
+ attemptId: attempt.id,
444
+ reason,
445
+ detail,
446
+ actor: "reconciler"
447
+ }).catch(() => false)) result.stalled.push({
448
+ taskId: attempt.taskId,
449
+ attemptId: attempt.id,
450
+ memberId: attempt.memberId,
451
+ reason,
452
+ detail
453
+ });
454
+ }
455
+ }
456
+ async function healTerminalAttempts() {
457
+ try {
458
+ const healed = await ports.taskStore.closeTerminalAttempts();
459
+ if (healed.closed.length > 0) logger(`live-teams: closed ${healed.closed.length} attempt(s) whose task had already ended: ${healed.closed.map((entry) => `${entry.attemptId} (${entry.status})`).join(", ")}`);
460
+ const pruned = await ports.taskStore.pruneSnapshots();
461
+ if (pruned > 0) logger(`live-teams: removed ${pruned} stale scope snapshot(s)`);
462
+ const orphans = await ports.mailbox.healOrphanMessages().catch(() => []);
463
+ for (const orphan of orphans) logger(`live-teams: message ${orphan.messageId} had no delivery row; recorded a failed delivery to ${orphan.recipient}`);
464
+ if (ports.gitTip !== void 0) {
465
+ const interrupted = await ports.taskStore.reconcilePendingIntegrations({
466
+ tipOf: ports.gitTip,
467
+ actor: "reconciler"
468
+ });
469
+ for (const entry of interrupted) logger(`live-teams: integration pending on ${entry.taskId} (${entry.state}): ${entry.detail}`);
470
+ }
471
+ } catch (error) {
472
+ logger(`live-teams: could not close stale attempts: ${error instanceof Error ? error.message : String(error)}`);
473
+ }
474
+ }
475
+ /**
476
+ * Mark attempts a human wrote into mid-flight (B31). Only active attempts are
477
+ * observed: the mark matters while work is in progress, and once set it persists on
478
+ * the record, so a later pass cannot miss it.
479
+ */
480
+ async function observeInterventions(result) {
481
+ if (ports.humanActivity === void 0) {
482
+ if (!warnedUnobservable) {
483
+ warnedUnobservable = true;
484
+ logger("live-teams: human intervention cannot be observed in this composition; no attempt will be marked");
485
+ }
486
+ return;
487
+ }
488
+ const attempts = (await ports.taskStore.readAttempts()).attempts.filter((attempt) => attempt.status === "active" && attempt.humanIntervenedAt === void 0);
489
+ for (const attempt of attempts) {
490
+ let observed;
491
+ try {
492
+ observed = await ports.humanActivity(attempt.sessionId, attempt.startedAt);
493
+ } catch (error) {
494
+ logger(`live-teams: could not observe Session ${attempt.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
495
+ continue;
496
+ }
497
+ if (observed !== true) continue;
498
+ if (!(await ports.taskStore.markHumanIntervention(attempt.id, now())).changed) continue;
499
+ result.intervened.push({
500
+ attemptId: attempt.id,
501
+ taskId: attempt.taskId,
502
+ memberId: attempt.memberId
503
+ });
504
+ }
505
+ }
506
+ async function pass(trigger) {
507
+ const result = {
508
+ trigger,
509
+ paused: false,
510
+ evaluated: 0,
511
+ started: [],
512
+ woke: [],
513
+ blocked: [],
514
+ skipped: [],
515
+ intervened: [],
516
+ stalled: [],
517
+ failures: []
518
+ };
519
+ const state = await ports.readTeam();
520
+ const dispatch = readDispatchSwitch(state);
521
+ result.paused = dispatch.mode === "paused";
522
+ await healTerminalAttempts();
523
+ await wakeHalf(result);
524
+ await observeInterventions(result);
525
+ await observeStalledAttempts(state, result);
526
+ if (dispatch.mode === "auto") await startHalf(state, result);
527
+ return result;
528
+ }
529
+ /**
530
+ * One pass, guarded by a single-flight latch. The latch is cleared from a
531
+ * continuation registered *after* it is set: clearing it inside the pass's own
532
+ * `finally` runs synchronously when the pass refuses before its first `await`,
533
+ * which would leave the latch holding a settled promise and turn every later
534
+ * reconcile into a replay of that first refusal (found by the B29 host check).
535
+ */
536
+ function reconcile(trigger) {
537
+ if (stopped) return Promise.resolve({
538
+ trigger,
539
+ paused: false,
540
+ evaluated: 0,
541
+ started: [],
542
+ woke: [],
543
+ blocked: [],
544
+ skipped: [],
545
+ intervened: [],
546
+ stalled: [],
547
+ failures: ["the dispatcher is stopped"]
548
+ });
549
+ if (running !== void 0) {
550
+ rerun = true;
551
+ return running;
552
+ }
553
+ const attempt = runPass(trigger);
554
+ running = attempt;
555
+ const settle = () => {
556
+ if (running === attempt) running = void 0;
557
+ if (rerun && !stopped) {
558
+ rerun = false;
559
+ reconcile("coalesced");
560
+ }
561
+ };
562
+ attempt.then(settle, settle);
563
+ return attempt;
564
+ }
565
+ async function runPass(trigger) {
566
+ try {
567
+ if (ports.enabled?.() === false) return {
568
+ trigger,
569
+ paused: false,
570
+ evaluated: 0,
571
+ started: [],
572
+ woke: [],
573
+ blocked: [],
574
+ skipped: [],
575
+ intervened: [],
576
+ stalled: [],
577
+ failures: [ports.inactiveReason?.() ?? "the team is not active"]
578
+ };
579
+ return await pass(trigger);
580
+ } catch (error) {
581
+ const message = error instanceof LiveTeamsError ? error.message : error instanceof Error ? error.message : String(error);
582
+ logger(`live-teams: dispatch pass failed (${trigger}): ${message}`);
583
+ return {
584
+ trigger,
585
+ paused: false,
586
+ evaluated: 0,
587
+ started: [],
588
+ woke: [],
589
+ blocked: [],
590
+ skipped: [],
591
+ intervened: [],
592
+ stalled: [],
593
+ failures: [message]
594
+ };
595
+ }
596
+ }
597
+ return {
598
+ reconcile,
599
+ notice: (event) => {
600
+ const kind = typeof event?.kind === "string" ? event.kind : "";
601
+ if (kind.length === 0 || kind.startsWith("dispatch/") || !TRIGGER_KINDS.test(kind)) return;
602
+ reconcile(`event:${kind}`);
603
+ },
604
+ start: () => {
605
+ if (stopped || timer !== void 0) return;
606
+ const interval = setInterval(() => {
607
+ reconcile("sweep");
608
+ }, sweepIntervalMs);
609
+ interval.unref?.();
610
+ timer = interval;
611
+ reconcile("startup");
612
+ },
613
+ stop: async () => {
614
+ stopped = true;
615
+ if (timer !== void 0) clearInterval(timer);
616
+ timer = void 0;
617
+ try {
618
+ await running;
619
+ } catch {}
620
+ }
621
+ };
622
+ }
623
+ //#endregion
624
+ export { DISPATCH_REASONS, assignmentText, assignmentThread, createDispatcher, evaluateTask, projectDispatch };
@@ -0,0 +1,36 @@
1
+ //#region src/errors.d.ts
2
+ /** Stable host-side error taxonomy and platform translation. */
3
+ export declare const ERROR_CODES: Readonly<{
4
+ readonly SESSION_NOT_FOUND: "SESSION_NOT_FOUND";
5
+ readonly SESSION_WRONG_WORKSPACE: "SESSION_WRONG_WORKSPACE";
6
+ readonly SESSION_BUSY: "SESSION_BUSY";
7
+ readonly DELIVERY_REJECTED: "DELIVERY_REJECTED";
8
+ readonly MODEL_UNAVAILABLE: "MODEL_UNAVAILABLE";
9
+ readonly INTERRUPT_UNSUPPORTED: "INTERRUPT_UNSUPPORTED";
10
+ readonly PLUGIN_DISPOSING: "PLUGIN_DISPOSING";
11
+ readonly CAPABILITY_UNAVAILABLE: "CAPABILITY_UNAVAILABLE";
12
+ readonly REVISION_CONFLICT: "REVISION_CONFLICT";
13
+ readonly BINDING_STALE: "BINDING_STALE";
14
+ readonly SCHEMA_UNSUPPORTED: "SCHEMA_UNSUPPORTED";
15
+ readonly STATE_CORRUPT: "STATE_CORRUPT";
16
+ /** ADR 0006: the reviewed base moved before integration, so the decision must be refreshed. */
17
+ readonly STALE_BASE: "STALE_BASE";
18
+ }>;
19
+ export type ErrorCode = keyof typeof ERROR_CODES;
20
+ export type ErrorOptions = {
21
+ cause?: unknown;
22
+ auditDetail?: string;
23
+ details?: Record<string, unknown>;
24
+ };
25
+ /** Audit text used when a member was rebound after command admission. */
26
+ export declare const BINDING_STALE_MESSAGE = "the member was rebound after this command was admitted";
27
+ export declare class LiveTeamsError extends Error {
28
+ readonly code: ErrorCode;
29
+ readonly auditDetail: string | undefined;
30
+ readonly details: Record<string, unknown>;
31
+ constructor(code: ErrorCode | string, message: string, options?: ErrorOptions);
32
+ }
33
+ /** Translate platform failures into stable domain errors. */
34
+ export declare function translatePlatformError(error: unknown): LiveTeamsError;
35
+ export declare function platformErrorMappings(): ReadonlyMap<string, ErrorCode>;
36
+ //#endregion