@sigrun/sigrun 0.7.0 → 0.9.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 (56) hide show
  1. package/CHANGELOG.md +78 -59
  2. package/README.md +32 -0
  3. package/dist/config.js +47 -0
  4. package/dist/curation/host.js +125 -21
  5. package/dist/curation/runs.js +15 -4
  6. package/dist/extension.d.ts +331 -4
  7. package/dist/harness/boot.js +114 -3
  8. package/dist/harness/complete.js +127 -0
  9. package/dist/harness/decision-tool.js +93 -0
  10. package/dist/harness/decisions.js +477 -0
  11. package/dist/harness/event-bus.js +12 -0
  12. package/dist/harness/interactions.js +81 -0
  13. package/dist/harness/interactive-tools.js +97 -0
  14. package/dist/harness/loader.js +285 -1
  15. package/dist/harness/packages.js +7 -0
  16. package/dist/harness/published-hooks.js +208 -0
  17. package/dist/harness/seam-host.js +149 -1
  18. package/dist/harness/settings.js +41 -0
  19. package/dist/history/git.js +6 -2
  20. package/dist/jobs/fire.js +10 -0
  21. package/dist/jobs/observe.js +27 -2
  22. package/dist/notices/events.js +8 -0
  23. package/dist/notices/service.js +297 -0
  24. package/dist/notices/store.js +331 -0
  25. package/dist/notices/validate.js +311 -0
  26. package/dist/server/conversation-ops.js +50 -0
  27. package/dist/server/core-views/skills-tree.js +3 -12
  28. package/dist/server/core-views/skills.js +11 -23
  29. package/dist/server/host-bridge.js +62 -1
  30. package/dist/server/index.js +26 -0
  31. package/dist/server/routes/events.js +20 -0
  32. package/dist/server/routes/extensions.js +5 -0
  33. package/dist/server/routes/interactions.js +48 -1
  34. package/dist/server/routes/meta.js +17 -0
  35. package/dist/server/routes/notices.js +94 -0
  36. package/dist/server/routes/providers.js +65 -8
  37. package/dist/server/routes/submissions.js +3 -0
  38. package/dist/server/routes/users.js +4 -0
  39. package/dist/server/surfaces/dedup.js +6 -0
  40. package/dist/server/visibility.js +30 -0
  41. package/dist/settings/registry.js +18 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  43. package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
  44. package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
  45. package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
  46. package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
  47. package/node_modules/@sigrun/shared/dist/notices.js +49 -0
  48. package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
  49. package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
  50. package/node_modules/@sigrun/shared/package.json +9 -1
  51. package/package.json +7 -7
  52. package/webui/dist/assets/index-D5KowS7k.css +1 -0
  53. package/webui/dist/assets/index-DGIrLC6F.js +55 -0
  54. package/webui/dist/index.html +2 -2
  55. package/webui/dist/assets/index-BI5k-Oci.js +0 -55
  56. package/webui/dist/assets/index-EGluyRXk.css +0 -1
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Task 1 (knowledge plan): the published-hook registry — the pure core of
3
+ * the extension-to-extension call seam. Given the loader's declarations
4
+ * (configured extensions first, then built-ins; first per name owns), it
5
+ * builds a registry whose `call` validates the payload against the
6
+ * provider's compiled input schema, resolves the acting-user identity,
7
+ * runs the provider under a linked abort controller plus a deadline, and
8
+ * bounds payload size, result size, and recursion depth. `call` NEVER
9
+ * throws; validators compile ONCE here at build, and a schema that fails
10
+ * `Compile` throws FROM THE BUILD (Task 2 turns that into a load-time
11
+ * quarantine, so the build never sees one in production).
12
+ */
13
+ import { AsyncLocalStorage } from "node:async_hooks";
14
+ import { Compile } from "typebox/compile";
15
+ /** Legal hook names: dotted lowercase segments (`memory.recall`). */
16
+ export const PUBLISHED_HOOK_NAME = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
17
+ /** Hook names are also bounded in length. */
18
+ export const PUBLISHED_HOOK_NAME_MAX_CHARS = 128;
19
+ /** Maximum hook-call nesting before `failed/too-deep`. */
20
+ export const PUBLISHED_HOOK_MAX_DEPTH = 4;
21
+ /** Payload and result size cap (1 MiB of JSON). */
22
+ export const PUBLISHED_HOOK_MAX_BYTES = 1_048_576;
23
+ const depthStorage = new AsyncLocalStorage();
24
+ function errorMessage(err) {
25
+ return err instanceof Error ? err.message : String(err);
26
+ }
27
+ function describeErrors(validator, value) {
28
+ const out = [];
29
+ for (const err of validator.Errors(value)) {
30
+ out.push({ path: err.instancePath, message: err.message ?? "invalid" });
31
+ }
32
+ return out.length > 0 ? out : [{ path: "", message: "invalid" }];
33
+ }
34
+ export function buildPublishedHookRegistry(declarations, deps) {
35
+ // Ownership: the declarations arrive in loader order; configured
36
+ // extensions sort BEFORE built-ins regardless of arrival order, and
37
+ // the first per name owns it. Shadowed providers record under the
38
+ // owner (configured losers BEFORE built-in losers) and each loser
39
+ // warns once.
40
+ const ordered = [...declarations].sort((a, b) => Number(a.builtin) - Number(b.builtin));
41
+ const owners = new Map();
42
+ const shadowedBy = new Map();
43
+ for (const declaration of ordered) {
44
+ const hook = declaration.hook;
45
+ const existing = owners.get(hook.name);
46
+ if (existing) {
47
+ const owner = existing.declaration.extension;
48
+ const loser = declaration.extension;
49
+ const advice = declaration.builtin
50
+ ? `replace the built-in ${loser} by package name, or remove ${owner}`
51
+ : "reorder extensions[] to switch";
52
+ deps.warn(`published hook "${hook.name}": owned by ${owner}, shadowed ${loser} — ${advice}`);
53
+ const list = shadowedBy.get(hook.name);
54
+ if (list)
55
+ list.push(loser);
56
+ else
57
+ shadowedBy.set(hook.name, [loser]);
58
+ continue;
59
+ }
60
+ owners.set(hook.name, {
61
+ declaration,
62
+ input: Compile(hook.input),
63
+ output: hook.output !== undefined ? Compile(hook.output) : undefined,
64
+ });
65
+ }
66
+ async function call(caller, name, payload, opts = {}) {
67
+ // Depth is checked BEFORE ownership lookup so a nested call under
68
+ // an unknown name still reports too-deep rather than absent.
69
+ const depth = depthStorage.getStore() ?? 0;
70
+ if (depth >= PUBLISHED_HOOK_MAX_DEPTH) {
71
+ return {
72
+ status: "failed",
73
+ provider: owners.get(name)?.declaration.extension ?? "?",
74
+ reason: "too-deep",
75
+ error: `published hook depth ${depth + 1} exceeds ${PUBLISHED_HOOK_MAX_DEPTH}`,
76
+ };
77
+ }
78
+ const entry = owners.get(name);
79
+ if (!entry)
80
+ return { status: "absent" };
81
+ const provider = entry.declaration.extension;
82
+ let serialized;
83
+ try {
84
+ serialized = JSON.stringify(payload);
85
+ }
86
+ catch (err) {
87
+ return {
88
+ status: "rejected",
89
+ provider,
90
+ errors: [{ path: "", message: `payload is not JSON-serializable: ${errorMessage(err)}` }],
91
+ };
92
+ }
93
+ if (serialized.length > PUBLISHED_HOOK_MAX_BYTES) {
94
+ return {
95
+ status: "rejected",
96
+ provider,
97
+ errors: [
98
+ {
99
+ path: "",
100
+ message: `payload is ${serialized.length} bytes; the cap is ${PUBLISHED_HOOK_MAX_BYTES}`,
101
+ },
102
+ ],
103
+ };
104
+ }
105
+ if (!entry.input.Check(payload)) {
106
+ return { status: "rejected", provider, errors: describeErrors(entry.input, payload) };
107
+ }
108
+ let user;
109
+ if (opts.conversationId !== undefined) {
110
+ user = await deps.actingUser(opts.conversationId);
111
+ if (opts.user !== undefined && opts.user !== user) {
112
+ return {
113
+ status: "rejected",
114
+ provider,
115
+ errors: [
116
+ {
117
+ path: "user",
118
+ message: `caller declares user "${opts.user}" but the conversation's acting user is "${user ?? "unknown"}"`,
119
+ },
120
+ ],
121
+ };
122
+ }
123
+ }
124
+ else {
125
+ user = opts.user;
126
+ }
127
+ const controller = new AbortController();
128
+ let timedOut = false;
129
+ let callerAborted = false;
130
+ const timer = setTimeout(() => {
131
+ timedOut = true;
132
+ controller.abort();
133
+ }, deps.deadlineMs);
134
+ const onCallerAbort = () => {
135
+ callerAborted = true;
136
+ controller.abort();
137
+ };
138
+ opts.abortSignal?.addEventListener("abort", onCallerAbort, { once: true });
139
+ const hookCall = {
140
+ caller,
141
+ user,
142
+ conversationId: opts.conversationId,
143
+ abortSignal: controller.signal,
144
+ };
145
+ let value;
146
+ try {
147
+ value = await depthStorage.run(depth + 1, () => entry.declaration.hook.run(payload, hookCall));
148
+ }
149
+ catch (err) {
150
+ const reason = timedOut ? "timeout" : callerAborted ? "aborted" : "threw";
151
+ return { status: "failed", provider, reason, error: errorMessage(err) };
152
+ }
153
+ finally {
154
+ clearTimeout(timer);
155
+ opts.abortSignal?.removeEventListener("abort", onCallerAbort);
156
+ }
157
+ let resultJson;
158
+ try {
159
+ resultJson = JSON.stringify(value);
160
+ }
161
+ catch (err) {
162
+ return {
163
+ status: "failed",
164
+ provider,
165
+ reason: "too-large",
166
+ error: `result is not JSON-serializable: ${errorMessage(err)}`,
167
+ };
168
+ }
169
+ if (resultJson.length > PUBLISHED_HOOK_MAX_BYTES) {
170
+ return {
171
+ status: "failed",
172
+ provider,
173
+ reason: "too-large",
174
+ error: `result is ${resultJson.length} bytes; the cap is ${PUBLISHED_HOOK_MAX_BYTES}`,
175
+ };
176
+ }
177
+ if (entry.output && !entry.output.Check(value)) {
178
+ const first = describeErrors(entry.output, value)[0];
179
+ return {
180
+ status: "failed",
181
+ provider,
182
+ reason: "bad-output",
183
+ error: `result fails the declared output schema at ${first?.path ?? ""}: ${first?.message ?? "invalid"}`,
184
+ };
185
+ }
186
+ return { status: "ok", provider, value };
187
+ }
188
+ return {
189
+ get(name) {
190
+ const entry = owners.get(name);
191
+ if (!entry)
192
+ return undefined;
193
+ return {
194
+ provider: entry.declaration.extension,
195
+ input: entry.declaration.hook.input,
196
+ output: entry.declaration.hook.output,
197
+ };
198
+ },
199
+ call,
200
+ listing() {
201
+ return [...owners.entries()].map(([name, entry]) => ({
202
+ name,
203
+ provider: entry.declaration.extension,
204
+ shadowed: shadowedBy.get(name) ?? [],
205
+ }));
206
+ },
207
+ };
208
+ }
@@ -17,7 +17,7 @@
17
17
  * extension; a throw later (tool execute) surfaces to the caller.
18
18
  */
19
19
  import { AmbientDoc, AmbientOwnersDoc, validateAmbient, } from "./ambient.js";
20
- import { InteractionDoc, InteractionsIndexDoc, applyAnswer, asInteractionRecord, indexPending, indexSettled, sweepSecretDelivery, takeSecretDelivery, validateInteraction, } from "./interactions.js";
20
+ import { InteractionDoc, InteractionsIndexDoc, QueuedHumanInputsDoc, applyAnswer, applyDecline, asInteractionRecord, indexPending, indexSettled, sweepSecretDelivery, takeSecretDelivery, validateInteraction, } from "./interactions.js";
21
21
  import { toDurableContext } from "./context.js";
22
22
  import { scopedExtensionStores, } from "./stores.js";
23
23
  /** One seam violation. Name carries the surface ("interaction"/"ambient"). */
@@ -212,11 +212,79 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
212
212
  throw new SeamError("interaction", problem);
213
213
  context = toDurableContext(context);
214
214
  const h = await harness(context);
215
+ // A declinable record seeded after a human message is immediately
216
+ // declined when that message is still queued (ask-user Task 2,
217
+ // spec 5.3): the message was admitted BEFORE the card existed and
218
+ // must never get stuck behind it. Read each recorded submission's
219
+ // current status here (outside the commit — submission status is
220
+ // a table read) and drop any entry that is no longer queued.
221
+ let preDeclineUser;
222
+ let preDeclined = false;
223
+ const stillQueuedIds = new Set();
224
+ const seen = new Set();
225
+ if (record.declinable === true) {
226
+ const queuedDoc = await h.snapshot(QueuedHumanInputsDoc, conversationId, context);
227
+ const byId = queuedDoc?.byId ?? {};
228
+ // Snapshot key set: the commit-time cleanup below may only drop
229
+ // keys THIS read saw. A submission recorded by a concurrent
230
+ // submitOp ledger commit that lands between the snapshot and our
231
+ // commit is not in `seen` and must survive.
232
+ for (const key of Object.keys(byId))
233
+ seen.add(key);
234
+ const stillQueued = [];
235
+ for (const [submissionId] of Object.entries(byId)) {
236
+ const submission = await h.submission(Number(submissionId), context);
237
+ if (submission === undefined)
238
+ continue; // gone — drop below
239
+ const status = await submission.status(context);
240
+ // Fork isolation: QueuedHumanInputsDoc forks with the
241
+ // conversation, so a child inherits the parent's entries.
242
+ // Only same-conversation submissions count as queued here.
243
+ if (status.status === "queued" &&
244
+ status.conversationId === conversationId) {
245
+ stillQueued.push(submissionId);
246
+ stillQueuedIds.add(submissionId);
247
+ }
248
+ }
249
+ if (stillQueued.length > 0) {
250
+ // Earliest recorded submission wins (ascending id order).
251
+ stillQueued.sort((a, b) => Number(a) - Number(b));
252
+ preDeclineUser = byId[stillQueued[0]]?.user;
253
+ preDeclined = true;
254
+ }
255
+ }
215
256
  await h.commit(async (tx) => {
216
257
  // Maintain the index in the SAME commit as the record.
217
258
  const existing = await tx.doc(InteractionDoc, conversationId, record.callId, record.callId);
218
259
  if (existing.status !== "pending")
219
260
  return; // first-write wins
261
+ if (record.declinable === true) {
262
+ // Drop ledger entries whose submission is no longer queued
263
+ // (they were read above; only stale ones are removed).
264
+ const queuedDraft = await tx.doc(QueuedHumanInputsDoc, conversationId);
265
+ const mutableById = queuedDraft.byId;
266
+ for (const key of Object.keys(mutableById)) {
267
+ if (seen.has(key) && !stillQueuedIds.has(key)) {
268
+ delete mutableById[key];
269
+ }
270
+ }
271
+ }
272
+ if (preDeclined) {
273
+ // Seed already settled: the human's queued message declines
274
+ // the question before it is ever shown. Reuses applyDecline's
275
+ // single definition of the settled shape.
276
+ Object.assign(existing, record);
277
+ // A self-declared-terminal seed (the extension passed a
278
+ // non-pending status) must NOT survive the assign: the
279
+ // queued human message declined it, so applyDecline stamps
280
+ // declinedVia/answeredBy on a pending baseline.
281
+ Object.assign(existing, { status: "pending" });
282
+ applyDecline(existing, "message", Date.now(), preDeclineUser);
283
+ // No index entry — it is not pending. Sweep any secret
284
+ // delivery staged for this record (defensive).
285
+ sweepSecretDelivery(conversationId, record.callId);
286
+ return;
287
+ }
220
288
  Object.assign(existing, record);
221
289
  const index = await tx.doc(InteractionsIndexDoc);
222
290
  indexPending(index, conversationId, record.callId);
@@ -264,6 +332,22 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
264
332
  return true;
265
333
  }, context);
266
334
  },
335
+ async declineInteraction(conversationId, callId, context, opts) {
336
+ context = toDurableContext(context);
337
+ const h = await harness(context);
338
+ // Same commit shape as settleInteraction: the sweep lands INSIDE
339
+ // the commit and only on the applied terminal transition.
340
+ return await h.commit(async (tx) => {
341
+ const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
342
+ const outcome = applyDecline(draft, "button", Date.now(), opts?.user);
343
+ if (outcome !== "declined")
344
+ return false;
345
+ const index = await tx.doc(InteractionsIndexDoc);
346
+ indexSettled(index, conversationId, callId);
347
+ sweepSecretDelivery(conversationId, callId);
348
+ return true;
349
+ }, context);
350
+ },
267
351
  async peekInteraction(conversationId, callId, context) {
268
352
  // The non-consuming inspection read (release-readiness-2 §1):
269
353
  // the SAME stored projection as snapshotInteraction, with NO
@@ -422,6 +506,12 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
422
506
  async get(id) {
423
507
  return (await hostBridge("jobs.get")).jobs.get(id);
424
508
  },
509
+ async activeRun(conversationId) {
510
+ return (await hostBridge("jobs.activeRun")).jobs.activeRun(conversationId);
511
+ },
512
+ async isJobRun(conversationId, context) {
513
+ return (await hostBridge("jobs.isJobRun")).jobs.isJobRun(conversationId, context);
514
+ },
425
515
  },
426
516
  users: {
427
517
  async list() {
@@ -530,6 +620,29 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
530
620
  async list() {
531
621
  return (await hostBridge("models.list")).models.list();
532
622
  },
623
+ async complete(input) {
624
+ return (await hostBridge("models.complete")).models.complete(input);
625
+ },
626
+ },
627
+ // Warden Task 6: the decisions surface delegates to the host
628
+ // bridge (the bridge owns the registry's body); `caller` is the
629
+ // identity name read AT CALL TIME (the same rekey-aware pattern as
630
+ // `publishedHooks.call` — the host rekeys in place), so a calling
631
+ // extension can never claim another identity.
632
+ decisions: {
633
+ async evaluate(request, opts = {}) {
634
+ const bridge = await hostBridge("decisions.evaluate");
635
+ if (bridge.decisions === undefined) {
636
+ return { ok: false, code: "no_model", error: "no decision provider is registered" };
637
+ }
638
+ return bridge.decisions.evaluate(identity.name, request, opts);
639
+ },
640
+ async list() {
641
+ const bridge = await hostBridge("decisions.list");
642
+ if (bridge.decisions === undefined)
643
+ return [];
644
+ return bridge.decisions.list();
645
+ },
533
646
  },
534
647
  commands: {
535
648
  async list() {
@@ -542,6 +655,19 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
542
655
  async notify(input) {
543
656
  return (await hostBridge("notify")).notify(identity.name, input);
544
657
  },
658
+ // Notices Task 3: the per-user notices channel — the bridge owns
659
+ // every body, the seam host supplies its slug (the notify pattern).
660
+ notices: {
661
+ async post(input) {
662
+ return (await hostBridge("notices.post")).notices.post(identity.name, input);
663
+ },
664
+ async clear(selector) {
665
+ return (await hostBridge("notices.clear")).notices.clear(identity.name, selector);
666
+ },
667
+ async list(user) {
668
+ return (await hostBridge("notices.list")).notices.list(identity.name, user);
669
+ },
670
+ },
545
671
  // The surfaces seam (UI-blocks U3 Task 4): `changed` awaits the
546
672
  // bridge (so a factory-body call waits, never resolves early) and
547
673
  // delegates to the server-side SurfaceService — the rev bump + the
@@ -574,6 +700,28 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
574
700
  });
575
701
  },
576
702
  },
703
+ // Knowledge C7: the seam's published-hook surface. `get` reads the
704
+ // slot directly (undefined while loading still runs); `call` waits
705
+ // for the registry-ready deferred, then delegates with the caller
706
+ // identity read AT CALL TIME (the host rekeys in place).
707
+ publishedHooks: {
708
+ get(name) {
709
+ return options?.publishedHooks?.current.registry?.get(name);
710
+ },
711
+ async call(name, payload, opts) {
712
+ const slot = options?.publishedHooks;
713
+ if (slot === undefined) {
714
+ // No registry wiring (a bare-loader test rig): every name is
715
+ // absent — `call` never throws.
716
+ return { status: "absent" };
717
+ }
718
+ await slot.ready;
719
+ const registry = slot.current.registry;
720
+ if (registry === undefined)
721
+ return { status: "absent" };
722
+ return registry.call(identity.name, name, payload, opts);
723
+ },
724
+ },
577
725
  bindBridge(bridge) {
578
726
  bridgeBound = true;
579
727
  bridgeReleased(bridge);
@@ -91,6 +91,8 @@ const SETTING_KEYS = [
91
91
  "jobHistory",
92
92
  "defaultModel",
93
93
  "namingModel",
94
+ "decisionModel",
95
+ "decisions",
94
96
  "defaultProvider",
95
97
  "skills",
96
98
  "notifications",
@@ -326,6 +328,33 @@ function validate(value) {
326
328
  throw new SettingsValidationError("namingModel", "must have a non-empty provider and model id");
327
329
  }
328
330
  }
331
+ if (value.decisionModel !== undefined) {
332
+ // Exactly defaultModel's spec rules (no `false` disable — a
333
+ // decision call without a default resolves the first-registered
334
+ // provider's first model).
335
+ const spec = value.decisionModel;
336
+ if (typeof spec !== "string" || spec.trim() === "") {
337
+ throw new SettingsValidationError("decisionModel", "must be a non-empty provider/model-id string");
338
+ }
339
+ const trimmed = spec.trim();
340
+ const slash = trimmed.indexOf("/");
341
+ if (slash !== -1 &&
342
+ (trimmed.slice(0, slash) === "" || trimmed.slice(slash + 1) === "")) {
343
+ throw new SettingsValidationError("decisionModel", "must have a non-empty provider and model id");
344
+ }
345
+ }
346
+ // The decision surface's toggles (warden Task 6): a flat section —
347
+ // `tool` a boolean.
348
+ if (value.decisions !== undefined) {
349
+ const decisions = asRecord(value.decisions);
350
+ if (decisions === undefined) {
351
+ throw new SettingsValidationError("decisions", "must be an object");
352
+ }
353
+ checkKeys("decisions.", decisions, ["tool"]);
354
+ if (decisions.tool !== undefined && typeof decisions.tool !== "boolean") {
355
+ throw new SettingsValidationError("decisions.tool", "must be a boolean");
356
+ }
357
+ }
329
358
  if (value.defaultProvider !== undefined) {
330
359
  const provider = asRecord(value.defaultProvider);
331
360
  if (provider === undefined) {
@@ -418,6 +447,7 @@ function validateServer(server) {
418
447
  "dev",
419
448
  "supervised",
420
449
  "backup",
450
+ "decisions",
421
451
  ]);
422
452
  // The jobs default timezone (§8.2): an IANA name — `Intl` accepts
423
453
  // exactly the names croner's evaluation path resolves, and throws on
@@ -570,6 +600,17 @@ function validateServer(server) {
570
600
  }
571
601
  }
572
602
  }
603
+ if (record.decisions !== undefined) {
604
+ const decisions = asRecord(record.decisions);
605
+ if (decisions === undefined) {
606
+ throw new SettingsValidationError("server.decisions", "must be an object");
607
+ }
608
+ checkKeys("server.decisions.", decisions, ["timeoutMs"]);
609
+ if (decisions.timeoutMs !== undefined &&
610
+ !isPositiveInt(decisions.timeoutMs)) {
611
+ throw new SettingsValidationError("server.decisions.timeoutMs", "must be a positive integer");
612
+ }
613
+ }
573
614
  if (record.isolation !== undefined) {
574
615
  const isolation = asRecord(record.isolation);
575
616
  if (isolation === undefined) {
@@ -490,8 +490,12 @@ export class GitHistory {
490
490
  }
491
491
  /** Corrupt git dir → move aside, re-init, snapshot commit (loud log). */
492
492
  async recoverCorrupt() {
493
- const brokenName = `${this.spec.gitDir.split(sep).pop() ?? "repo"}.broken-${utcStamp(this.now())}`;
494
- const brokenPath = `${this.spec.gitDir}.broken-${utcStamp(this.now())}`;
493
+ // ONE stamp: the summary must name the directory the rename made —
494
+ // two utcStamp(now) calls can straddle a clock tick and name a
495
+ // path that was never created (seen as a CI flake).
496
+ const stamp = utcStamp(this.now());
497
+ const brokenName = `${this.spec.gitDir.split(sep).pop() ?? "repo"}.broken-${stamp}`;
498
+ const brokenPath = `${this.spec.gitDir}.broken-${stamp}`;
495
499
  await rename(this.spec.gitDir, brokenPath);
496
500
  this.logLine(`corrupt git dir moved aside: ${this.spec.gitDir} → ${brokenPath}`);
497
501
  await this.run(["init", "--initial-branch=main"]);
package/dist/jobs/fire.js CHANGED
@@ -38,6 +38,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
38
38
  import { parseModelSpec } from "../config.js";
39
39
  import { expandInput } from "../commands/expand.js";
40
40
  import { SessionMetaDoc } from "../server/session-meta.js";
41
+ import { excludeInteractiveTools } from "../harness/interactive-tools.js";
41
42
  import { noteThen } from "../server/turns/submit-turn.js";
42
43
  import { recordAuthor } from "../curation/attribution.js";
43
44
  import { JobRunDoc, patchJob, readJob } from "./docs.js";
@@ -172,6 +173,11 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
172
173
  if (prior === undefined) {
173
174
  await deps.harness.commit(async (tx) => {
174
175
  await tx.doc(JobRunDoc, fireKey, run);
176
+ // NO meta.jobId stamp here (the 2026-11-05 reconciliation with
177
+ // ask-user's isJobRun): a fire INTO an existing conversation must
178
+ // not mark every later run of that conversation job-driven. The
179
+ // runSettled observer settles this run from the submission's
180
+ // `job:<fireKey>` requestId instead (observe.ts).
175
181
  }, context);
176
182
  await patchJob(deps.harness, id, (draft) => {
177
183
  draft.lastRun = run;
@@ -233,6 +239,10 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
233
239
  if (owner !== undefined)
234
240
  meta.owner = owner;
235
241
  await deps.rewriteUserSelection?.(tx, newId, undefined, owner);
242
+ // Ask-user Task 3: the job-run conversation never offers an
243
+ // interactive tool (the tool is never listed, so the model
244
+ // never tries — §6's prompt section keys off tool presence).
245
+ await excludeInteractiveTools(tx, newId, deps.interactiveTools ?? new Set());
236
246
  if (action.runSharing !== undefined)
237
247
  meta.sharing = action.runSharing;
238
248
  meta.name = runName(record.spec.name, deps.now(), record, deps);
@@ -66,9 +66,34 @@ export function registerJobObservers(deps) {
66
66
  // auto-namer — a job observation never delays an extension's).
67
67
  const runSettled = async (event) => {
68
68
  try {
69
+ // The job to settle from, in resolution order:
70
+ // 1. the session meta's `jobId` (a `conversation: "new"` fire —
71
+ // the stamp persists for the conversation's life);
72
+ // 2. a `job:<fireKey>` requestId on one of the run's inputs (the
73
+ // FIXED-conversation fire shape — `jobs/fire.ts` stamps the
74
+ // prefix on BOTH shapes; a fire INTO an existing conversation
75
+ // deliberately leaves the meta unstamped so later ordinary
76
+ // runs there are not job runs, per isJobRun's contract).
77
+ let jobId;
69
78
  const meta = await deps.harness.snapshot(SessionMetaDoc, event.conversationId, ctx);
70
- const jobId = meta?.jobId;
71
- if (typeof jobId !== "string")
79
+ const metaJobId = meta?.jobId;
80
+ if (typeof metaJobId === "string") {
81
+ jobId = metaJobId;
82
+ }
83
+ else {
84
+ for (const id of event.inputs) {
85
+ const submission = await deps.harness.submission(id, ctx);
86
+ if (submission === undefined)
87
+ continue;
88
+ const record = await submission.status(ctx);
89
+ const requestId = record.requestId;
90
+ if (requestId !== undefined && requestId.startsWith("job:")) {
91
+ jobId = requestId.slice("job:".length).split(":")[0];
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ if (jobId === undefined)
72
97
  return;
73
98
  const runs = await listRuns(deps.storage, jobId, Number.MAX_SAFE_INTEGER, ctx);
74
99
  const run = runs.find((one) => one.outcome === "running" &&
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The hub-internal notice.changed event (notices spec §4): the wire shape
3
+ * PLUS the recipient `user`, attached at publish time (the store's
4
+ * `NoticeChange` always names its user). The events route reads `user`
5
+ * and strips it before delivery — the wire shape carries no user, and a
6
+ * notice event never crosses to another principal's stream.
7
+ */
8
+ export {};