humanish 0.128.0 → 0.129.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.129.0: Analysis reads long runs past 100,000 items; traces past 32 MiB get their own advice; shared-world skips counted apart (2026-10-13)
12
+
13
+ humanish 0.129.0 fixes three things long studies reach. Analysis no longer refuses a run by how
14
+ many events, trace items or screenshot frames it holds; it still reads each file only up to 16
15
+ MiB. A trace or live file past 32 MiB, the most the trace loader reads, is refused with advice
16
+ that fits, a running participant whose live file passes 32 MiB keeps the items the Observer page
17
+ already shows, and the Observer server reads run.json under that limit. Shared-world runs count a
18
+ participant skipped before its session apart from those that used the app, and no longer count a
19
+ skip as overlap. This is a minor release because a shared-world `skippedReason` is now a sentence
20
+ where it was `handoff-timeout` or `host-ended-before-handoff`.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.129.0)
23
+
11
24
  ## 0.128.0: Run bundle v4 keeps per-request lists in trace files; the study budget is checked before each request (2026-10-13)
12
25
 
13
26
  humanish 0.128.0 keeps run.json small in long studies. Run bundles are now
@@ -35,7 +35,6 @@ function parseSource(prepared, bytes) {
35
35
  !Array.isArray(value.streams) ||
36
36
  value.streams.length > EVIDENCE_LIMITS.participants ||
37
37
  !Array.isArray(value.events) ||
38
- value.events.length > 100000 ||
39
38
  (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving)))
40
39
  throw new Error("ANALYSIS_SOURCE_INVALID");
41
40
  const ids = new Set();
@@ -79,10 +78,8 @@ async function readSource(prepared, bytes, traceBytes) {
79
78
  return loaded.bundle;
80
79
  }
81
80
  function validateTraceItems(actor) {
82
- if (actor !== undefined &&
83
- (!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
81
+ if (actor !== undefined && (!isRecord(actor) || !Array.isArray(actor.items)))
84
82
  throw new Error("ANALYSIS_SOURCE_INVALID");
85
- }
86
83
  const eventIds = new Set();
87
84
  for (const item of isRecord(actor) ? actor.items : []) {
88
85
  if (!isRecord(item) ||
@@ -150,7 +150,7 @@ const analysisEvidenceSchema = z
150
150
  quoteEligible: z.boolean(),
151
151
  at: timestamp.nullable(),
152
152
  elapsedMs: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
153
- frame: z.number().int().min(0).max(100000).nullable(),
153
+ frame: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
154
154
  capture: z
155
155
  .object({
156
156
  eventId: sourceId,
@@ -138,7 +138,8 @@ export function formatConcurrentSharedWorldStudyHuman(result) {
138
138
  ? "Participants used the app at the same time (observed in this run only)."
139
139
  : "Participants were not observed using the app at the same time.",
140
140
  ]),
141
- ...result.roles.map((participant) => `${participantCaption({ id: participant.id, personaId: participant.persona })}: ${participantStatus(participant.status)}${participant.ok ? "" : ", not ok"}`),
141
+ // A skipped participant's line ends on the reason the run recorded, as on computer use.
142
+ ...result.roles.map((participant) => `${participantCaption({ id: participant.id, personaId: participant.persona })}: ${participantStatus(participant.status)}${participant.ok ? "" : ", not ok"}${participant.skippedReason === undefined ? "" : ` · ${participant.skippedReason}`}`),
142
143
  ...(result.subjectSandbox
143
144
  ? [
144
145
  `subject sandbox: ${sandboxName(result.subjectSandbox)} killed=${result.subjectSandbox.killed ? "yes" : "no"}`,
@@ -9,6 +9,8 @@ import type { ObserverItems } from "./participant-items.js";
9
9
  export declare function parseFeedCursors(after: string | null): ReadonlyMap<string, LiveItemsCursor>;
10
10
  /**
11
11
  * `items` with each live file's cursor on the entry of the stream it was joined into, and
12
- * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it.
12
+ * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it. A
13
+ * live file the loader stopped reading (LiveItemsRead's `stopped`) keeps the page's cursor as its
14
+ * own, so the page holds its items and sends that cursor again.
13
15
  */
14
16
  export declare function withFeedCursors(items: ObserverItems, live: readonly LiveItemsRead[]): ObserverItems;
@@ -43,7 +43,9 @@ function cursorText(path, position) {
43
43
  }
44
44
  /**
45
45
  * `items` with each live file's cursor on the entry of the stream it was joined into, and
46
- * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it.
46
+ * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it. A
47
+ * live file the loader stopped reading (LiveItemsRead's `stopped`) keeps the page's cursor as its
48
+ * own, so the page holds its items and sends that cursor again.
47
49
  */
48
50
  export function withFeedCursors(items, live) {
49
51
  const streams = Object.assign(Object.create(null), items.streams);
@@ -51,9 +53,10 @@ export function withFeedCursors(items, live) {
51
53
  const entry = Object.hasOwn(streams, read.streamId) ? streams[read.streamId] : undefined;
52
54
  if (entry === undefined || !Array.isArray(entry.liveActor))
53
55
  continue;
56
+ const end = read.stopped === undefined ? read.index : read.after;
54
57
  streams[read.streamId] = {
55
58
  ...entry,
56
- live: cursorText(read.index.path, read.index),
59
+ live: cursorText(read.index.path, end),
57
60
  ...(read.after === undefined ? {} : { liveAfter: cursorText(read.index.path, read.after) }),
58
61
  };
59
62
  }
@@ -10,6 +10,7 @@ import { listRuns } from "../run/stored-runs.js";
10
10
  import { readAnalysisAccounting } from "../run/costs.js";
11
11
  import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
12
12
  import { isRunOutcome } from "../run/bundle-shape.js";
13
+ import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
13
14
  import { displayedTraces, loadStreamTraces, } from "../run/stream-trace.js";
14
15
  import { isNodeError, isRecord } from "../run/type-guards.js";
15
16
  import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-files.js";
@@ -150,10 +151,17 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
150
151
  writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
151
152
  return;
152
153
  }
154
+ // run.json is read under the limit every other reader of it uses, by name as through the feed.
155
+ const runJson = cleanedRelativePath === "run.json";
153
156
  try {
154
- const body = await readContainedFile(runRoot, filePath, notePath ? { maxBytes: MAX_NOTE_FILE_BYTES } : {});
157
+ const body = await readContainedFile(runRoot, filePath, notePath
158
+ ? { maxBytes: MAX_NOTE_FILE_BYTES }
159
+ : runJson
160
+ ? { maxBytes: RUN_ARTIFACT_MAX_BYTES }
161
+ : {});
155
162
  if (!body) {
156
- writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
163
+ const tooLarge = runJson ? await pastReadLimit(runRoot, "run.json") : undefined;
164
+ writeResponse(response, 404, tooLarge === undefined ? "Not found" : `Not found: ${tooLarge}`, "text/plain; charset=utf-8");
157
165
  return;
158
166
  }
159
167
  response.writeHead(200, {
@@ -198,12 +206,15 @@ async function readObserverData(runRoot, runtimeStreamUrls = []) {
198
206
  * fallback. The saved observer-data.json is read only while the run has no run.json at all, or in
199
207
  * safe serving, where only its admitted bytes can be read. Otherwise a run.json that is there and
200
208
  * cannot be read, parsed or used as a bundle (which a browser poll can meet mid-write) gives the
201
- * reason, a 404 the poller retries, never the saved projection.
209
+ * reason, a 404 the poller retries, never the saved projection. Both files are read under
210
+ * RUN_ARTIFACT_MAX_BYTES, as every other reader of a run file reads them.
202
211
  */
203
212
  async function observerDataOrReason(runRoot, runtimeStreamUrls, cursors) {
204
213
  const record = await readLocalRunStatus(runRoot);
205
214
  const runJson = path.join(runRoot.physicalPath, "run.json");
206
- const bundleBytes = await readContainedFile(runRoot, runJson).catch(() => null);
215
+ const bundleBytes = await readContainedFile(runRoot, runJson, {
216
+ maxBytes: RUN_ARTIFACT_MAX_BYTES,
217
+ }).catch(() => null);
207
218
  const absent = bundleBytes === null &&
208
219
  (await lstat(runJson).then(() => false, (error) => isNodeError(error) && error.code === "ENOENT"));
209
220
  // A root admitted for safe serving checks every read against the identity and digest admitted
@@ -214,7 +225,10 @@ async function observerDataOrReason(runRoot, runtimeStreamUrls, cursors) {
214
225
  return "unavailable" in saved ? saved : { data: saved, live: [] };
215
226
  }
216
227
  if (bundleBytes === null)
217
- return { unavailable: "run.json is there but could not be read: it is a link or changed." };
228
+ return {
229
+ unavailable: (await pastReadLimit(runRoot, "run.json")) ??
230
+ "run.json is there but could not be read: it is a link or changed.",
231
+ };
218
232
  let stored;
219
233
  try {
220
234
  const parsed = JSON.parse(bundleBytes.toString("utf8"));
@@ -237,12 +251,31 @@ async function observerDataOrReason(runRoot, runtimeStreamUrls, cursors) {
237
251
  return { unavailable: "run.json could not be shown as a run." };
238
252
  }
239
253
  }
254
+ /**
255
+ * Why run.json or a saved observer-data.json was not read, when the file at `relativePath` passes
256
+ * the root's checks and is larger than RUN_ARTIFACT_MAX_BYTES, the most the server reads of either;
257
+ * otherwise undefined. The size comes from the opened file, so no link or file outside the run
258
+ * folder is measured.
259
+ */
260
+ async function pastReadLimit(runRoot, relativePath) {
261
+ const opened = await openContainedFile(runRoot, path.join(runRoot.physicalPath, relativePath));
262
+ if (opened === null)
263
+ return undefined;
264
+ await opened.handle.close().catch(() => undefined);
265
+ return opened.size > RUN_ARTIFACT_MAX_BYTES
266
+ ? `${relativePath} is larger than ${RUN_ARTIFACT_MAX_BYTES} bytes, the most humanish reads of it.`
267
+ : undefined;
268
+ }
240
269
  /** The saved observer-data.json of a run that has written no run.json yet. */
241
270
  async function savedObserverData(runRoot, record, runtimeStreamUrls) {
242
271
  try {
243
- const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"));
244
- if (!observerBytes)
272
+ const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"), { maxBytes: RUN_ARTIFACT_MAX_BYTES });
273
+ if (!observerBytes) {
274
+ const tooLarge = await pastReadLimit(runRoot, "observer/observer-data.json");
275
+ if (tooLarge !== undefined)
276
+ return { unavailable: tooLarge };
245
277
  throw new Error("observer-data.json unavailable");
278
+ }
246
279
  const saved = observerDataFromSaved(JSON.parse(observerBytes.toString("utf8")));
247
280
  if (saved === null)
248
281
  throw new Error("observer-data.json holds no participant's items");
@@ -4,7 +4,7 @@ import { actorEnding } from "../../actors/stop-cause.js";
4
4
  import { cuaGoalSource } from "../../actors/goal-source.js";
5
5
  import { redactText } from "../../evidence/redaction.js";
6
6
  import { REVIEW_SCHEMA, bundleArtifacts, bundleHead, } from "../../run/bundle.js";
7
- import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, withCuaReviewProvenance, } from "../../run/outcomes.js";
7
+ import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, skippedSentence, withCuaReviewProvenance, } from "../../run/outcomes.js";
8
8
  import { providerResourcesForOutcome, publicSafeAppUrlLabel } from "./bundle-parts.js";
9
9
  import { subjectName } from "../../run/subject-name.js";
10
10
  import { participantFeedbackCandidates } from "./participant-feedback.js";
@@ -63,12 +63,9 @@ function fanoutSummary(args, facts) {
63
63
  // A run where every participant was skipped has no one to describe in this sentence.
64
64
  if (tookPart > 0)
65
65
  sentences.push(`${plural(tookPart, "participant")} took part, ${tookPart === 1 ? "in its own copy" : "each in their own copy"} of the app: ${outcomes}${gaps}.`);
66
- // The gaps are pointed at once: here only when the sentence before did not.
67
- const why = gaps === "" ? "; the gaps say why" : "";
68
- if (facts.skipped === 1)
69
- sentences.push(`1 participant was skipped before its session started${why}.`);
70
- if (facts.skipped > 1)
71
- sentences.push(`${facts.skipped} participants were skipped before their sessions started${why}.`);
66
+ const skipped = skippedSentence(facts.skipped, gaps === "");
67
+ if (skipped !== "")
68
+ sentences.push(skipped);
72
69
  return `${host}${rerun}${sentences.join(" ")}${tasks}`;
73
70
  }
74
71
  /**
@@ -67,7 +67,12 @@ function sharedWorldSimulation(ctx, spec, index, view) {
67
67
  mode: "browser-sim",
68
68
  progress: inProgress ? 35 : 100,
69
69
  currentStep: view.reason,
70
- summary: participantSummary(name, { name: "the shared app" }, { trace: session?.trace, sessionError: outcome?.sessionError, inProgress }),
70
+ summary: participantSummary(name, { name: "the shared app" }, {
71
+ trace: session?.trace,
72
+ skippedReason: outcome?.skippedReason,
73
+ sessionError: outcome?.sessionError,
74
+ inProgress,
75
+ }),
71
76
  startedAt: args.run.createdAt,
72
77
  updatedAt: args.run.createdAt,
73
78
  ...(args.plan.arrivals.declared
@@ -7,6 +7,14 @@ export declare function maxSimultaneousWindows(windows: Array<{
7
7
  startedAt: number;
8
8
  endedAt: number;
9
9
  }>): number;
10
+ /**
11
+ * The windows of the participants that used the app, for overlap and the live-at-once count. A
12
+ * participant the run skipped before its session started never used it, though its window sits
13
+ * inside another participant's session. run.json's laneWindows keep that window: verify expects
14
+ * one per declared participant, and reads overlap from them only on a pass, which a run with a
15
+ * skipped participant cannot reach.
16
+ */
17
+ export declare function usedAppWindows(results: readonly ActorRunResult[]): ActorRunResult[];
10
18
  /**
11
19
  * The judgment for a shared-world bundle's inputs: how each participant ended, plus what the run
12
20
  * observed about its world: overlap, a state change under overlap on the provisioned plane, and
@@ -16,7 +16,7 @@ import { hostOriginDigest } from "./provenance.js";
16
16
  import { participantEvent, participantIds } from "../../run/participant-records.js";
17
17
  import { sharedWorldParticipantRecords } from "./bundle-records.js";
18
18
  import { CONCURRENT_ATTRIBUTION_LIMITS, EXTERNAL_PUBLIC_ATTRIBUTION_LIMITS, } from "./types.js";
19
- import { gapsListClause } from "../../run/outcomes.js";
19
+ import { gapsListClause, skippedSentence } from "../../run/outcomes.js";
20
20
  import { plural } from "../../run/text.js";
21
21
  import { describeArrivals } from "../../study/arrivals.js";
22
22
  /** Max windows live at the same instant (sweep over start/end points). The simultaneity
@@ -38,6 +38,16 @@ export function maxSimultaneousWindows(windows) {
38
38
  }
39
39
  return max;
40
40
  }
41
+ /**
42
+ * The windows of the participants that used the app, for overlap and the live-at-once count. A
43
+ * participant the run skipped before its session started never used it, though its window sits
44
+ * inside another participant's session. run.json's laneWindows keep that window: verify expects
45
+ * one per declared participant, and reads overlap from them only on a pass, which a run with a
46
+ * skipped participant cannot reach.
47
+ */
48
+ export function usedAppWindows(results) {
49
+ return results.filter((result) => result.outcome.skippedReason === undefined);
50
+ }
41
51
  /**
42
52
  * The judgment for a shared-world bundle's inputs: how each participant ended, plus what the run
43
53
  * observed about its world: overlap, a state change under overlap on the provisioned plane, and
@@ -52,11 +62,7 @@ export function judgeSharedWorldRun(args) {
52
62
  expected: args.actorSpecs.length,
53
63
  participants: args.actorResults.map((result) => participantFactsOf(result.outcome)),
54
64
  world: {
55
- // The same windows and state series the bundle writes, read by verify's pass gate too.
56
- ...concurrencyFacts(args.actorSpecs.map((_spec, index) => ({
57
- startedAt: args.actorResults[index]?.startedAt ?? 0,
58
- endedAt: args.actorResults[index]?.endedAt ?? 0,
59
- })), external ? undefined : args.stateSnapshots),
65
+ ...concurrencyFacts(usedAppWindows(args.actorResults), external ? undefined : args.stateSnapshots),
60
66
  ...(external ? { lobbyConvergence: args.lobbyConvergenceDigest !== undefined } : {}),
61
67
  },
62
68
  });
@@ -68,8 +74,10 @@ export function actorRunPassed(result) {
68
74
  * The run's summary in plain sentences: who took part, how many reached the goal, and what the
69
75
  * world showed. A participant reached the goal when its session passed the judge's checks and
70
76
  * ended at the goal, so a productive `budget_reached` session does not count even when the lobby
71
- * converged. Each participant's ending stays in `sharedWorld.outcomes`, and the plane, topology
72
- * and attribution limits stay in the bundle's events and `sharedWorld` block.
77
+ * converged. A participant the run skipped, such as a follower stopped at the handoff, never used
78
+ * the app, so it is counted in a sentence of its own. Each participant's ending stays in
79
+ * `sharedWorld.outcomes`, and the plane, topology and attribution limits stay in the bundle's
80
+ * events and `sharedWorld` block.
73
81
  */
74
82
  function sharedWorldSummary(args, facts) {
75
83
  const count = args.actorSpecs.length;
@@ -79,16 +87,20 @@ function sharedWorldSummary(args, facts) {
79
87
  const served = facts.external ? ", as it is deployed" : ", served from a sandbox";
80
88
  return `Dry run: ${people} would use ${app} at the same time${served}. No sandboxes were launched and $0 was spent.`;
81
89
  }
90
+ // The live summary is written once, before any participant starts, so it names the plan.
82
91
  if (facts.inProgress) {
83
- return `${people} ${count === 1 ? "is" : "are"} using ${app} at the same time. The run is still going, so nothing here is final.`;
92
+ return `${people} ${count === 1 ? "is planned, in" : "are planned, all in"} ${app}. The run is still going, so nothing here is final.`;
84
93
  }
85
94
  const passed = facts.outcomes.filter((outcome) => outcome.ok);
86
95
  const reached = passed.filter((outcome) => outcome.completionReason === "goal_satisfied").length;
87
- const together = facts.overlaps ? " at the same time" : ", but never at the same time";
96
+ const skipped = args.actorResults.filter((result) => result.outcome.skippedReason !== undefined).length;
97
+ const tookPart = count - skipped;
98
+ const together = tookPart === 1 ? "" : facts.overlaps ? " at the same time" : ", but never at the same time";
88
99
  // A participant who did not pass has a gap line; one who passed but stopped short has none.
100
+ const gaps = gapsListClause(tookPart - passed.length, tookPart);
89
101
  const rest = [
90
102
  passed.length > reached ? `; ${passed.length - reached} ended without reaching it` : "",
91
- gapsListClause(count - passed.length, count),
103
+ gaps,
92
104
  ].join("");
93
105
  const world = facts.external
94
106
  ? args.lobbyConvergenceDigest
@@ -97,7 +109,15 @@ function sharedWorldSummary(args, facts) {
97
109
  : facts.deltas === 0
98
110
  ? "The app's shared state did not change during the run."
99
111
  : `The app's shared state changed ${plural(facts.deltas, "time")} during the run.`;
100
- return `${people} used ${app}${together}. ${reached} of ${count} reached the goal${rest}. ${world}`;
112
+ return [
113
+ tookPart > 0
114
+ ? `${plural(tookPart, "participant")} used ${app}${together}. ${reached} of ${tookPart} reached the goal${rest}.`
115
+ : "",
116
+ skippedSentence(skipped, gaps === ""),
117
+ world,
118
+ ]
119
+ .filter((sentence) => sentence !== "")
120
+ .join(" ");
101
121
  }
102
122
  /** The run's first two events: its creation and the shared plane's provenance. */
103
123
  function planeEvents(args, external) {
@@ -261,7 +281,7 @@ function concurrencyReview(args, external, inProgress, evidence, events, nextEve
261
281
  // The count that matters is how many participants were live at once, which can be fewer than exist:
262
282
  // a 6-participant run capped at 3 must never read as 6-wide concurrency.
263
283
  const capForReport = plan.concurrency;
264
- const maxLive = maxSimultaneousWindows(windows);
284
+ const maxLive = maxSimultaneousWindows(usedAppWindows(args.actorResults));
265
285
  events.push({
266
286
  id: nextEventId("concurrency"),
267
287
  at: createdAt,
@@ -12,7 +12,8 @@ export declare function resolveActorEntryUrl(baseUrl: string, entry: string | un
12
12
  * follower joins through the real UI (a direct /lobby/CODE visit does not auto-join a non-member). */
13
13
  export declare function withLobbyCodeMission(spec: DesktopParticipantRun, code: string): DesktopParticipantRun;
14
14
  /** A follower blocked by an expired deadline or an ended host. It never opened a browser;
15
- * keep the actual reason rather than turning every upstream failure into a timeout. */
15
+ * keep the actual reason rather than turning every upstream failure into a timeout. The skip
16
+ * reason is a sentence in the form the computer-use skips use, since run lines and gaps print it. */
16
17
  export declare function makeBlockedFollowerOutcome(spec: DesktopParticipantRun, reason: string, timedOut: boolean): ParticipantRunOutcome;
17
18
  /**
18
19
  * A live run publishes an in-progress bundle before its participants start, whether or not an
@@ -63,7 +63,8 @@ export function withLobbyCodeMission(spec, code) {
63
63
  };
64
64
  }
65
65
  /** A follower blocked by an expired deadline or an ended host. It never opened a browser;
66
- * keep the actual reason rather than turning every upstream failure into a timeout. */
66
+ * keep the actual reason rather than turning every upstream failure into a timeout. The skip
67
+ * reason is a sentence in the form the computer-use skips use, since run lines and gaps print it. */
67
68
  export function makeBlockedFollowerOutcome(spec, reason, timedOut) {
68
69
  return {
69
70
  spec,
@@ -77,7 +78,9 @@ export function makeBlockedFollowerOutcome(spec, reason, timedOut) {
77
78
  noEngagement: true,
78
79
  selfReportedBlocker: false,
79
80
  harnessError: false,
80
- skippedReason: timedOut ? "handoff-timeout" : "host-ended-before-handoff",
81
+ skippedReason: timedOut
82
+ ? "skipped: the host did not reach a lobby within the handoff deadline; no desktop was created"
83
+ : "skipped: the host ended before it reached a lobby; no desktop was created",
81
84
  };
82
85
  }
83
86
  /**
@@ -8,7 +8,7 @@ import { validatePreparedRunArtifactPaths } from "../../run/paths.js";
8
8
  import { participantExecutionFailures, participantFactsOf, } from "../computer-use/participant-facts.js";
9
9
  import { participantCapWarning } from "../computer-use/participant-model.js";
10
10
  import { resolveSubjectState } from "../computer-use/subject-projection.js";
11
- import { actorRunPassed, buildConcurrentSharedWorldBundle, judgeSharedWorldRun, maxSimultaneousWindows, } from "./bundle.js";
11
+ import { actorRunPassed, buildConcurrentSharedWorldBundle, judgeSharedWorldRun, maxSimultaneousWindows, usedAppWindows, } from "./bundle.js";
12
12
  import { planeStateOf } from "./plan.js";
13
13
  import { buildSubjectProvenance, hostOriginDigest } from "./provenance.js";
14
14
  import {} from "./types.js";
@@ -47,9 +47,16 @@ function concurrentParticipantResults(actorSpecs, actorResults, dryRun) {
47
47
  }
48
48
  const session = result.outcome.session;
49
49
  const thisOk = participantOk(result);
50
+ const { skippedReason } = result.outcome;
51
+ // The same judged record run.json writes, so the run line and the bundle name one status.
52
+ const record = judgeParticipantRecords([participantFactsOf(result.outcome)], {
53
+ sessionLabel: "Participant",
54
+ missingSessionMessage: "Actor did not produce a terminal session.",
55
+ }).participants[0];
50
56
  return {
51
57
  ...base,
52
- status: session ? session.status : "failed",
58
+ status: record.status,
59
+ ...(skippedReason === undefined ? {} : { skippedReason }),
53
60
  ok: thisOk,
54
61
  window: { startedAt: result.startedAt, endedAt: result.endedAt },
55
62
  ...(session
@@ -70,10 +77,7 @@ function concurrentParticipantResults(actorSpecs, actorResults, dryRun) {
70
77
  : {
71
78
  error: {
72
79
  code: "HUMANISH_SHARED_WORLD_FAILED",
73
- message: judgeParticipantRecords([participantFactsOf(result.outcome)], {
74
- sessionLabel: "Participant",
75
- missingSessionMessage: "Actor did not produce a terminal session.",
76
- }).participants[0].notPassedMessage,
80
+ message: record.notPassedMessage,
77
81
  },
78
82
  }),
79
83
  };
@@ -306,7 +310,9 @@ export async function finishConcurrentRun(ctx, live, results, plane) {
306
310
  ? {}
307
311
  : { subjectSandbox: { sandboxId: subjectSandboxId, killed: subjectKilled } }),
308
312
  ...(dryRun ? {} : { overlapProven }),
309
- ...(dryRun ? {} : { maxSimultaneousLanes: maxSimultaneousWindows(actorResults) }),
313
+ ...(dryRun
314
+ ? {}
315
+ : { maxSimultaneousLanes: maxSimultaneousWindows(usedAppWindows(actorResults)) }),
310
316
  subject,
311
317
  roles: participantResults,
312
318
  observer,
@@ -94,6 +94,8 @@ export interface ConcurrentSharedWorldParticipantResult {
94
94
  sandboxIdDigest?: string;
95
95
  killed: boolean;
96
96
  };
97
+ /** Set when the run skipped the participant before its session started (status `blocked`). */
98
+ skippedReason?: string;
97
99
  error?: {
98
100
  code: ConcurrentSharedWorldStudyErrorCode;
99
101
  message: string;
@@ -53,6 +53,13 @@ interface SummarySession {
53
53
  export declare function participantSummary(name: string, app: SummaryApp, session: SummarySession): string;
54
54
  /** "; the gaps list the other 2", or "all 3" when nobody passed. Empty when the gaps list nobody. */
55
55
  export declare function gapsListClause(listed: number, total: number): string;
56
+ /**
57
+ * The sentence that counts the participants a run skipped before their sessions started, or ""
58
+ * when it skipped none. A skipped participant never used the app, so a summary counts it here and
59
+ * leaves it out of the sentence about those that took part. `pointAtGaps` is false when that
60
+ * sentence already points at the gaps.
61
+ */
62
+ export declare function skippedSentence(skipped: number, pointAtGaps: boolean): string;
56
63
  /** One line a stakeholder can read, with the denominator attached to every number. */
57
64
  export declare function formatParticipantOutcomes(outcomes: ParticipantOutcomes, terminalCauses?: readonly ParticipantOutcomeDetail[]): string;
58
65
  /** Presentation details tolerate optional legacy actor payloads without changing their tallies. */
@@ -206,6 +206,20 @@ export function gapsListClause(listed, total) {
206
206
  return `; the gaps list the other ${listed}`;
207
207
  return total === 1 ? "; the gaps say why" : `; the gaps list all ${total}`;
208
208
  }
209
+ /**
210
+ * The sentence that counts the participants a run skipped before their sessions started, or ""
211
+ * when it skipped none. A skipped participant never used the app, so a summary counts it here and
212
+ * leaves it out of the sentence about those that took part. `pointAtGaps` is false when that
213
+ * sentence already points at the gaps.
214
+ */
215
+ export function skippedSentence(skipped, pointAtGaps) {
216
+ const why = pointAtGaps ? "; the gaps say why" : "";
217
+ if (skipped === 1)
218
+ return `1 participant was skipped before its session started${why}.`;
219
+ if (skipped > 1)
220
+ return `${skipped} participants were skipped before their sessions started${why}.`;
221
+ return "";
222
+ }
209
223
  /** One line a stakeholder can read, with the denominator attached to every number. */
210
224
  export function formatParticipantOutcomes(outcomes, terminalCauses = []) {
211
225
  if (outcomes.total === 0)
@@ -2,10 +2,13 @@ import { type RunBundle } from "./bundle.js";
2
2
  import { type PreparedOutputRoot } from "./contained-output.js";
3
3
  import { type LiveItemsCursor } from "./live-items.js";
4
4
  import type { LiveItemsIndex, RunStream } from "./streams.js";
5
- /** A trace that does not match its index, or cannot be read. The message names the stream. */
5
+ /**
6
+ * A trace that does not match its index, or cannot be read. The message names the stream, and ends
7
+ * with `advice`: by default, restore the run folder from a copy.
8
+ */
6
9
  export declare class StreamTraceRefusedError extends Error {
7
10
  readonly streamId: string;
8
- constructor(streamId: string, detail: string);
11
+ constructor(streamId: string, detail: string, advice?: string);
9
12
  }
10
13
  /** `trace` without the per-request lists; every other field as it is. */
11
14
  export declare function withoutRequestLists<T extends object>(trace: T): T;
@@ -39,13 +42,22 @@ export type TraceFileReader = (relativePath: string, maxBytes: number, length?:
39
42
  }>;
40
43
  /**
41
44
  * A live file the loader joined into stream `streamId`: the index it read the items by, and the
42
- * cursor it read past when the stream holds only the items after one.
45
+ * cursor it read past when the stream holds only the items after one. A read with `stopped` has
46
+ * an index that records more than the loader reads of one file, so it read nothing and joined no
47
+ * items past `after`, which it always has; `stopped` is the warning a reader that shows the run
48
+ * gives.
43
49
  */
44
- export interface LiveItemsRead {
50
+ export type LiveItemsRead = {
45
51
  streamId: string;
46
52
  index: LiveItemsIndex;
47
53
  after?: LiveItemsCursor;
48
- }
54
+ stopped?: undefined;
55
+ } | {
56
+ streamId: string;
57
+ index: LiveItemsIndex;
58
+ after: LiveItemsCursor;
59
+ stopped: string;
60
+ };
49
61
  /** For a live file's index, the prefix of that file the caller holds already, if any. */
50
62
  type LiveAfter = (index: LiveItemsIndex) => LiveItemsCursor | undefined;
51
63
  /**
@@ -53,7 +65,8 @@ type LiveAfter = (index: LiveItemsIndex) => LiveItemsCursor | undefined;
53
65
  * stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
54
66
  * stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
55
67
  * checked against the index's byte count, sha256, item count and screenshot count, and any
56
- * difference throws StreamTraceRefusedError. The rules that need the whole bundle (an unindexed v2
68
+ * difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
69
+ * before the file is read. The rules that need the whole bundle (an unindexed v2
57
70
  * actor, two participants indexing one file) are loadStreamTraces'.
58
71
  */
59
72
  export declare function loadStreamTrace(runPaths: PreparedOutputRoot, stream: RunStream, maxBytes?: number): Promise<RunStream>;
@@ -80,7 +93,9 @@ interface LoadedTraces {
80
93
  * `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
81
94
  * feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
82
95
  * without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
83
- * file's lines gets the whole prefix, checked as always.
96
+ * file's lines gets the whole prefix, checked as always. A live file whose index records more than
97
+ * `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
98
+ * `live` records `stopped`, and without one the stream is refused.
84
99
  *
85
100
  * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
86
101
  * removes a superseded generation once run.json names the next one, and every live file once the
@@ -97,11 +112,13 @@ export declare function loadStreamTraces(source: PreparedOutputRoot | TraceFileR
97
112
  * summary, shows none of the items or per-request lists run.json or a file supplied for it (its
98
113
  * actor's items and lists, its in-progress items, its screenshot entries), and carries
99
114
  * `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
100
- * shows the run then never prices or checks rows the loader refused.
115
+ * shows the run then never prices or checks rows the loader refused. A live file the loader
116
+ * stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
101
117
  */
102
118
  export declare function displayedTraces(loaded: {
103
119
  bundle: RunBundle;
104
120
  refused: readonly StreamTraceRefusedError[];
121
+ live?: readonly LiveItemsRead[];
105
122
  }): RunBundle;
106
123
  /** The bundle with every stream loaded; the first refusal throws. */
107
124
  export declare function loadRunTraces(source: PreparedOutputRoot | TraceFileReader, bundle: RunBundle, maxBytes?: number): Promise<RunBundle>;
@@ -13,15 +13,25 @@ import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsAft
13
13
  import { estimateActorCostForExecution } from "./pricing.js";
14
14
  import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
15
15
  import { isRecord } from "./type-guards.js";
16
- /** A trace that does not match its index, or cannot be read. The message names the stream. */
16
+ /**
17
+ * A trace that does not match its index, or cannot be read. The message names the stream, and ends
18
+ * with `advice`: by default, restore the run folder from a copy.
19
+ */
17
20
  export class StreamTraceRefusedError extends Error {
18
21
  streamId;
19
- constructor(streamId, detail) {
20
- super(`Participant ${streamId}'s trace was not read: ${detail}. A trace file must stay as the run wrote it; restore the run folder from a copy.`);
22
+ constructor(streamId, detail, advice = "A trace file must stay as the run wrote it; restore the run folder from a copy.") {
23
+ super(`Participant ${streamId}'s trace was not read: ${detail}. ${advice}`);
21
24
  this.streamId = streamId;
22
25
  this.name = "StreamTraceRefusedError";
23
26
  }
24
27
  }
28
+ /** Why the loader reads no trace or live file whose index records more than `maxBytes`. */
29
+ const pastLimitDetail = (index, maxBytes) => `run.json records ${index.path} at ${index.bytes} bytes, more than the ${maxBytes} bytes humanish reads of one file`;
30
+ /**
31
+ * The refusal of a trace or live file whose index records more than `maxBytes`. The loader reads
32
+ * no file past the limit, so a copy of the file is refused too.
33
+ */
34
+ const tooLarge = (streamId, index, maxBytes) => new StreamTraceRefusedError(streamId, pastLimitDetail(index, maxBytes), "Restoring a copy does not help; open the file with another tool, such as jq, to read its items.");
25
35
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
26
36
  const isScreenshot = (artifact) => artifact.kind === "screenshot";
27
37
  /**
@@ -250,10 +260,22 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
250
260
  return refuse("run.json records a malformed live items index");
251
261
  if (live.items !== undefined)
252
262
  refuse("run.json holds in-progress items beside their live file");
253
- if (index.bytes > maxBytes)
254
- refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
255
263
  const { itemsFile: _index, ...summary } = live;
256
264
  const after = liveAfter?.(index);
265
+ if (index.bytes > maxBytes) {
266
+ if (after === undefined || !cursorFits(index, after))
267
+ throw tooLarge(stream.id, index, maxBytes);
268
+ // The caller keeps the prefix it holds, read when the file was under the limit.
269
+ return {
270
+ stream: { ...stream, liveActor: { ...summary, items: [] } },
271
+ read: {
272
+ streamId: stream.id,
273
+ index,
274
+ after,
275
+ stopped: `Participant ${stream.id}'s items past ${after.items} were not read: ${pastLimitDetail(index, maxBytes)}. The items read before stay shown; open the file with another tool, such as jq, to read the rest.`,
276
+ },
277
+ };
278
+ }
257
279
  const tail = after === undefined ? undefined : await readLiveTail(read, stream, index, after);
258
280
  if (tail !== undefined)
259
281
  return {
@@ -274,6 +296,15 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
274
296
  read: { streamId: stream.id, index },
275
297
  };
276
298
  }
299
+ /**
300
+ * Whether `after` can be a prefix of the file `index` describes: some bytes, no more items or bytes
301
+ * than the index, and at its end in both counts or in neither. Whether it ends on a line end is
302
+ * checked only when the tail is read.
303
+ */
304
+ const cursorFits = (index, after) => after.bytes > 0 &&
305
+ after.items <= index.items &&
306
+ after.bytes <= index.bytes &&
307
+ (after.items === index.items) === (after.bytes === index.bytes);
277
308
  /**
278
309
  * The items of the live file `index` describes past `after`, and the bytes they were read from;
279
310
  * undefined when the cursor does not fit the index or the file's lines, so the caller reads the
@@ -281,10 +312,7 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
281
312
  * LiveFileUnreadableError, as a whole read does.
282
313
  */
283
314
  async function readLiveTail(read, stream, index, after) {
284
- if (after.bytes === 0 ||
285
- after.items > index.items ||
286
- after.bytes > index.bytes ||
287
- (after.items === index.items) !== (after.bytes === index.bytes))
315
+ if (!cursorFits(index, after))
288
316
  return undefined;
289
317
  if (after.bytes === index.bytes)
290
318
  return { items: [], bytes: Buffer.alloc(0) };
@@ -304,6 +332,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
304
332
  let joined = stream;
305
333
  if (stream.trace !== undefined) {
306
334
  assertIndex(stream);
335
+ if (stream.trace.bytes > maxBytes)
336
+ throw tooLarge(stream.id, stream.trace, maxBytes);
307
337
  const file = await read(stream.trace.path, maxBytes);
308
338
  if ("refused" in file)
309
339
  throw new StreamTraceRefusedError(stream.id, file.refused);
@@ -320,7 +350,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
320
350
  * stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
321
351
  * stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
322
352
  * checked against the index's byte count, sha256, item count and screenshot count, and any
323
- * difference throws StreamTraceRefusedError. The rules that need the whole bundle (an unindexed v2
353
+ * difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
354
+ * before the file is read. The rules that need the whole bundle (an unindexed v2
324
355
  * actor, two participants indexing one file) are loadStreamTraces'.
325
356
  */
326
357
  export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
@@ -387,7 +418,9 @@ function bundleRefusal(bundle, stream, owners) {
387
418
  * `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
388
419
  * feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
389
420
  * without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
390
- * file's lines gets the whole prefix, checked as always.
421
+ * file's lines gets the whole prefix, checked as always. A live file whose index records more than
422
+ * `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
423
+ * `live` records `stopped`, and without one the stream is refused.
391
424
  *
392
425
  * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
393
426
  * removes a superseded generation once run.json names the next one, and every live file once the
@@ -456,11 +489,13 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
456
489
  * summary, shows none of the items or per-request lists run.json or a file supplied for it (its
457
490
  * actor's items and lists, its in-progress items, its screenshot entries), and carries
458
491
  * `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
459
- * shows the run then never prices or checks rows the loader refused.
492
+ * shows the run then never prices or checks rows the loader refused. A live file the loader
493
+ * stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
460
494
  */
461
495
  export function displayedTraces(loaded) {
462
496
  const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
463
- if (reasons.size === 0)
497
+ const stopped = (loaded.live ?? []).filter((read) => read.stopped !== undefined);
498
+ if (reasons.size === 0 && stopped.length === 0)
464
499
  return loaded.bundle;
465
500
  const streams = loaded.bundle.streams.map((stream) => {
466
501
  const reason = reasons.get(stream.id);
@@ -484,7 +519,19 @@ export function displayedTraces(loaded) {
484
519
  message: reasons.get(stream.id),
485
520
  streamId: stream.id,
486
521
  }));
487
- return { ...loaded.bundle, streams, events: [...loaded.bundle.events, ...events] };
522
+ const notRead = stopped.map((read) => ({
523
+ id: `event-live-items-not-read-${read.streamId}`,
524
+ at: loaded.bundle.streams.find((stream) => stream.id === read.streamId)?.updatedAt ?? "",
525
+ level: "warn",
526
+ type: "live-items.not-read",
527
+ message: read.stopped,
528
+ streamId: read.streamId,
529
+ }));
530
+ return {
531
+ ...loaded.bundle,
532
+ streams,
533
+ events: [...loaded.bundle.events, ...events, ...notRead],
534
+ };
488
535
  }
489
536
  /** The bundle with every stream loaded; the first refusal throws. */
490
537
  export async function loadRunTraces(source, bundle, maxBytes) {
@@ -97,6 +97,19 @@ A page that cannot join a body sends no cursors with its next poll. Static
97
97
  operator path. Agents and CI should use `humanish watch --json --no-open` for
98
98
  the same fresh evidence without browser open or a long-running process.
99
99
 
100
+ The feed reads no live file whose index passes the 32 MiB read limit
101
+ (`RUN_ARTIFACT_MAX_BYTES`); a request for the file by its path still gets the whole file. A page
102
+ that sends a cursor for it keeps the items it holds: the
103
+ server answers with no items past the cursor, gives the page's cursor back as `live`, and adds a
104
+ `live-items.not-read` warning to the participant's timeline, so later polls send the same cursor
105
+ and get the same answer. A poll that sends no cursor for that file gets the participant's trace
106
+ as unavailable, with the file's size and the limit, and the page drops any items it held. Such a
107
+ poll comes from a page opened after the file passed the limit or after the writer started a new
108
+ generation, from the poll after a body the page could not join, and from a page whose cursors
109
+ pass 8,000 characters (`MAX_AFTER_CHARS` in `observer/lib/live.ts`), which sends none. When the
110
+ run finishes, a trace file past the limit is refused too, and every page shows that trace as
111
+ unavailable.
112
+
100
113
  ### Reopening a run
101
114
 
102
115
  The TUI's **Open in Observer** action opens an HTTP view of the selected run's saved
@@ -54,7 +54,12 @@ file that does not match its index. Inside humanish, readers load a stream throu
54
54
  `loadStreamTrace(runPaths, stream)`. A v1 stream returns as it is.
55
55
  An indexed stream's file is read under the 32 MiB per-file limit (analysis uses its 16 MiB
56
56
  source limit for each trace) and refused unless its size, sha256, item count and screenshot count
57
- match the index. A trace file belongs to one participant: every stream whose index names a file
57
+ match the index. A trace or live file whose index records more than the limit is refused before it
58
+ is read, and the refusal says the file is larger than humanish reads of one file and that
59
+ restoring a copy does not help. A trace file whose index is under the limit and whose bytes are
60
+ over it changed after the run wrote it, and its refusal says to restore the run folder from a copy.
61
+ A live file may hold bytes past its index while the writer appends; the loader reads only the
62
+ indexed bytes. A trace file belongs to one participant: every stream whose index names a file
58
63
  another stream's index names, compared without case as a case-insensitive file system compares
59
64
  names, is refused before any file is read. The writer claims names the same way. In a v2 run.json every `actor`
60
65
  must have an index: an actor with no index, with
@@ -133,8 +138,9 @@ Readers that show a run (the Observer server, run detail and the TUI, the review
133
138
  provenance) load traces through the same loader and rules. A participant whose trace the loader
134
139
  refused shows none of the items run.json or a file supplied for it: its stream carries
135
140
  `traceUnavailable` with the reason, its Observer timeline a `trace.unavailable` warning, and its
136
- run-detail entry `evidenceUnavailable`. The Observer server reads the saved observer-data.json
137
- only while a run has no run.json at all; a run.json that is there and cannot be read, parsed or
141
+ run-detail entry `evidenceUnavailable`. The Observer server reads run.json, and the saved
142
+ observer-data.json, under the same 32 MiB limit; past it the server answers 404 with a reason
143
+ naming the limit. It reads the saved observer-data.json only while a run has no run.json at all; a run.json that is there and cannot be read, parsed or
138
144
  used as a bundle answers 404 with the reason, never the saved projection. In safe serving
139
145
  (`observe --all --safe`) every read is checked against the files admitted with the run's
140
146
  share_ready verification, so a run.json changed after admission is refused and the admitted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.128.0",
3
+ "version": "0.129.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",