humanish 0.119.0 → 0.120.1

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,36 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.120.1: Analysis requests under Bun run past 5 minutes (2026-10-09)
12
+
13
+ humanish 0.120.1 lets an analysis request from a Bun process run longer than 5 minutes. Under Bun,
14
+ `undici` is Bun's built-in module, whose `fetch` ignores the analysis provider's dispatcher and
15
+ ended each request after 300 seconds, so an analysis whose model took longer, as a 34-participant
16
+ run's cohort requests did at 325 to 405 seconds, failed with `analysis_provider_network_error`.
17
+ The provider now passes `timeout: false`, which Bun reads and Node's undici ignores, and the
18
+ request's own `timeoutMs` ends it under either runtime. A `TimeoutError` from `fetch` is recorded
19
+ as `provider_client_timeout`.
20
+
21
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.120.1)
22
+
23
+ ## 0.120.0: Analysis completes under Bun, runs past 16 participants print a bounded list, Observer counts participants by status (2026-10-09)
24
+
25
+ humanish 0.120.0 completes an analysis run from a Bun process, prints a run of more than 16
26
+ participants in a bounded number of lines, and counts and filters the Observer's participants by
27
+ status and persona. Under Bun, `undici` is Bun's built-in module, whose `Agent` has no `destroy()`,
28
+ and the analysis provider's teardown after each request threw a `TypeError` that replaced the
29
+ request's result, so every automatic analysis from a Bun process ended `unknown` with
30
+ `AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN` and kept no receipt. A failing teardown is now ignored.
31
+ `humanish watch` and `humanish run` print a computer-use run of more than 16 participants as one
32
+ line counting them by status, up to 16 participants that did not pass, and a `not listed:` line.
33
+ Each participant line holds its closing message on one line cut at 160 characters, and a warning
34
+ printed the same for each participant prints once. The Observer's participant grid counts the
35
+ participants by status when they ended more than one way, and pressing a count filters the grid.
36
+ Its view options gain a Persona filter, a filter change returns to the first page, and Next page
37
+ and Previous page open the new page at its first participant.
38
+
39
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.120.0)
40
+
11
41
  ## 0.119.0: Participants arrive over time, one analysis past 16 participants in cohorts, late starts skipped past the budget, study warnings redacted (2026-10-09)
12
42
 
13
43
  humanish 0.119.0 lets a computer-use or shared-world study start its participants over time, and
@@ -28,7 +28,7 @@ export interface AnalysisProviderResult {
28
28
  dispatched: boolean;
29
29
  /** Omitted preserves complete API-response accounting; account snapshots must state this. */
30
30
  usageComplete?: boolean;
31
- errorCode: "invalid_request" | "provider_http_error" | "provider_network_error" | "invalid_response" | "provider_headers_timeout" | "provider_body_timeout" | "provider_connect_timeout" | "provider_connection_reset" | "provider_dns_error" | "codex_busy" | "codex_unavailable" | "codex_unsupported_version" | "codex_incompatible_release" | "codex_unsupported_platform" | "codex_login_required" | "codex_unsupported_auth" | "codex_unsafe_configuration" | "codex_model_unavailable" | "codex_protocol_error" | "codex_tool_call" | "codex_process_failed" | "codex_cleanup_failed" | "response_too_large" | "output_incomplete" | "refusal" | "cancelled" | "timeout" | null;
31
+ errorCode: "invalid_request" | "provider_http_error" | "provider_network_error" | "invalid_response" | "provider_headers_timeout" | "provider_client_timeout" | "provider_body_timeout" | "provider_connect_timeout" | "provider_connection_reset" | "provider_dns_error" | "codex_busy" | "codex_unavailable" | "codex_unsupported_version" | "codex_incompatible_release" | "codex_unsupported_platform" | "codex_login_required" | "codex_unsupported_auth" | "codex_unsafe_configuration" | "codex_model_unavailable" | "codex_protocol_error" | "codex_tool_call" | "codex_process_failed" | "codex_cleanup_failed" | "response_too_large" | "output_incomplete" | "refusal" | "cancelled" | "timeout" | null;
32
32
  httpStatus?: number;
33
33
  /** Codex only: notification methods humanish does not know that carried no item, by count. */
34
34
  unknownNotifications?: Readonly<Record<string, number>>;
@@ -52,6 +52,11 @@ export type AnalysisFetch = (url: string, init: {
52
52
  headers: Record<string, string>;
53
53
  body: string;
54
54
  dispatcher: Dispatcher;
55
+ /**
56
+ * Bun's fetch ignores `dispatcher` and ends a request after 300 s unless this is false;
57
+ * Node's undici fetch ignores the field. The caller's timeoutMs governs either way.
58
+ */
59
+ timeout: false;
55
60
  }) => Promise<{
56
61
  ok: boolean;
57
62
  status: number;
@@ -34,6 +34,9 @@ function networkErrorCode(error) {
34
34
  const value = record(current);
35
35
  if (typeof value.code === "string" && Object.hasOwn(codes, value.code))
36
36
  return codes[value.code];
37
+ // Bun's fetch ends a request with a TimeoutError of its own, with no code.
38
+ if (value.name === "TimeoutError")
39
+ return "provider_client_timeout";
37
40
  current = value.cause;
38
41
  }
39
42
  return "provider_network_error";
@@ -177,6 +180,7 @@ export function createAnalysisProvider(options) {
177
180
  headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
178
181
  body,
179
182
  dispatcher,
183
+ timeout: false,
180
184
  });
181
185
  if (!response.ok) {
182
186
  // Never read provider error prose: it may echo evidence or credentials.
@@ -221,7 +225,14 @@ export function createAnalysisProvider(options) {
221
225
  finally {
222
226
  clearTimeout(timer);
223
227
  request.signal?.removeEventListener("abort", onAbort);
224
- await dispatcher.destroy().catch(() => undefined);
228
+ // Under Bun, `undici` is Bun's built-in module, whose Agent has no destroy(). Closing the
229
+ // agent must never replace the request's result, so a teardown that throws is ignored.
230
+ try {
231
+ await dispatcher.destroy();
232
+ }
233
+ catch {
234
+ // The agent belongs to this request alone; nothing else uses it after this point.
235
+ }
225
236
  }
226
237
  };
227
238
  }
@@ -50,6 +50,47 @@ function subjectLine(appUrl, subject) {
50
50
  function participantStatus(status) {
51
51
  return status === "contract_proof_only" ? "dry run, nothing ran live" : status;
52
52
  }
53
+ /** A warning the route adds for each participant prints once. */
54
+ function warningLines(warnings) {
55
+ return [...new Set(warnings)].map((warning) => `warning: ${warning}`);
56
+ }
57
+ /** How many participants a run lists by name, and how long a closing message may run in a list. */
58
+ const LISTED_PARTICIPANTS = 16;
59
+ const REASON_CHARS = 160;
60
+ /** A closing message on one line of at most REASON_CHARS characters. */
61
+ function oneLine(text) {
62
+ const line = text.replace(/\s+/g, " ").trim();
63
+ return line.length > REASON_CHARS ? `${line.slice(0, REASON_CHARS - 1).trimEnd()}…` : line;
64
+ }
65
+ function participantLine({ id, status, session, diagnostics }) {
66
+ return [
67
+ `participant ${id}: ${participantStatus(status)}${session ? ` (${session.completionReason})` : ""}`,
68
+ ...(diagnostics ? [formatCuaDiagnostics(diagnostics)] : []),
69
+ ...(session ? [oneLine(session.reason)] : []),
70
+ ].join(" · ");
71
+ }
72
+ /**
73
+ * A fan-out run's participants: one line each up to LISTED_PARTICIPANTS, which fits one terminal
74
+ * screen with the run's other lines. A larger run prints its count by status, the first
75
+ * LISTED_PARTICIPANTS that did not pass, and how many it left out.
76
+ */
77
+ function participantLines(participants) {
78
+ if (participants.length <= 1)
79
+ return [];
80
+ if (participants.length <= LISTED_PARTICIPANTS)
81
+ return participants.map(participantLine);
82
+ const counts = new Map();
83
+ for (const { status } of participants)
84
+ counts.set(participantStatus(status), (counts.get(participantStatus(status)) ?? 0) + 1);
85
+ const notPassed = participants.filter(({ status }) => status !== "passed" && status !== "contract_proof_only");
86
+ const listed = notPassed.slice(0, LISTED_PARTICIPANTS);
87
+ const unlistedNotPassed = notPassed.length - listed.length;
88
+ return [
89
+ `participants: ${participants.length} · ${[...counts].map(([status, count]) => `${count} ${status}`).join(" · ")}`,
90
+ ...listed.map(participantLine),
91
+ `not listed: ${participants.length - listed.length} participants${unlistedNotPassed > 0 ? ` (${unlistedNotPassed} did not pass)` : ""}. The Observer shows each one.`,
92
+ ];
93
+ }
53
94
  export function formatConcurrentSharedWorldStudyHuman(result) {
54
95
  return withError(result.error, [
55
96
  ...runHeader(result, "shared-world"),
@@ -78,7 +119,7 @@ export function formatConcurrentSharedWorldStudyHuman(result) {
78
119
  ...(result.observer?.opened === undefined
79
120
  ? []
80
121
  : [`opened: ${result.observer.opened ? "yes" : "no"}`]),
81
- ...result.warnings.map((warning) => `warning: ${warning}`),
122
+ ...warningLines(result.warnings),
82
123
  ]);
83
124
  }
84
125
  export function formatTerminalStudyHuman(result) {
@@ -95,7 +136,7 @@ export function formatTerminalStudyHuman(result) {
95
136
  ...(result.observer?.opened === undefined
96
137
  ? []
97
138
  : [`opened: ${result.observer.opened ? "yes" : "no"}`]),
98
- ...result.warnings.map((warning) => `warning: ${warning}`),
139
+ ...warningLines(result.warnings),
99
140
  ]);
100
141
  }
101
142
  /** `subject` is the study's declared subject, which names it when the result has no URL. */
@@ -121,7 +162,7 @@ export function formatScriptedStudyHuman(result, subject) {
121
162
  ...(result.observer?.opened === undefined
122
163
  ? []
123
164
  : [`opened: ${result.observer.opened ? "yes" : "no"}`]),
124
- ...result.warnings.map((warning) => `warning: ${warning}`),
165
+ ...warningLines(result.warnings),
125
166
  ]);
126
167
  }
127
168
  /** `subject` is the study's declared subject, which names it when the result has no URL. */
@@ -140,9 +181,7 @@ export function formatCuaStudyHuman(result, subject) {
140
181
  ? [`rerun: ${result.rerun.selectedLaneIds.join(", ")} from ${result.rerun.sourceRunId}`]
141
182
  : []),
142
183
  ...(result.diagnostics ? [`diagnostic: ${formatCuaDiagnostics(result.diagnostics)}`] : []),
143
- ...((result.lanes?.length ?? 0) > 1
144
- ? result.lanes.map((participant) => `participant ${participant.id}: ${participantStatus(participant.status)}${participant.session ? ` (${participant.session.completionReason})` : ""}${participant.diagnostics ? ` · ${formatCuaDiagnostics(participant.diagnostics)}` : ""}${participant.session ? ` · ${participant.session.reason}` : ""}`)
145
- : []),
184
+ ...participantLines(result.lanes ?? []),
146
185
  ...(result.session && (result.lanes?.length ?? 0) <= 1
147
186
  ? [
148
187
  `session: ${participantStatus(result.session.status)} (${result.session.completionReason})${result.session.stopCause ? ` · ${formatCuaStopCause(result.session.stopCause)}` : ""} · ${result.session.reason}`,
@@ -159,6 +198,6 @@ export function formatCuaStudyHuman(result, subject) {
159
198
  ...(result.observer?.opened === undefined
160
199
  ? []
161
200
  : [`opened: ${result.observer.opened ? "yes" : "no"}`]),
162
- ...result.warnings.map((warning) => `warning: ${warning}`),
201
+ ...warningLines(result.warnings),
163
202
  ]);
164
203
  }