humanish 0.133.1 → 0.134.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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/actors/computer-use/loop/actions.d.ts +2 -0
  3. package/dist/actors/computer-use/loop/actions.js +11 -5
  4. package/dist/actors/computer-use/loop/backstop.d.ts +23 -2
  5. package/dist/actors/computer-use/loop/backstop.js +40 -7
  6. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  7. package/dist/actors/computer-use/loop/session.js +3 -0
  8. package/dist/actors/computer-use/loop/streak.d.ts +18 -0
  9. package/dist/actors/computer-use/loop/streak.js +47 -0
  10. package/dist/actors/computer-use/loop/types.d.ts +5 -1
  11. package/dist/actors/computer-use/loop/watch.d.ts +22 -4
  12. package/dist/actors/computer-use/loop/watch.js +83 -27
  13. package/dist/actors/computer-use/loop.js +18 -6
  14. package/dist/actors/contract.d.ts +3 -1
  15. package/dist/analysis/service.js +3 -2
  16. package/dist/analysis/source.d.ts +6 -0
  17. package/dist/analysis/source.js +15 -13
  18. package/dist/cli/commands/run-signals.js +6 -2
  19. package/dist/observer/render.js +5 -3
  20. package/dist/routes/computer-use/bundle-parts.d.ts +9 -0
  21. package/dist/routes/computer-use/bundle-parts.js +24 -4
  22. package/dist/routes/computer-use/fanout-bundle.js +5 -3
  23. package/dist/routes/computer-use/single-bundle.js +2 -2
  24. package/dist/run/locate.d.ts +8 -2
  25. package/dist/run/locate.js +10 -4
  26. package/dist/run/run.js +18 -2
  27. package/dist/run/sandbox-ids.d.ts +4 -3
  28. package/dist/run/sandbox-ids.js +8 -6
  29. package/dist/run/stream-trace.d.ts +8 -2
  30. package/dist/run/stream-trace.js +13 -7
  31. package/dist/study/bundle-estimate.js +23 -17
  32. package/dist/verify/artifacts.d.ts +7 -1
  33. package/dist/verify/artifacts.js +15 -4
  34. package/dist/verify/verify.js +29 -8
  35. package/docs/architecture/actor-contract.md +3 -1
  36. package/docs/architecture/state-driven-executor.md +7 -4
  37. package/docs/contracts/run-bundle.md +13 -2
  38. package/docs/contracts/study-analysis.md +4 -2
  39. package/package.json +1 -1
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.134.0: Participants held to an end keep reloading until it (2026-10-14)
12
+
13
+ humanish 0.134.0 keeps a participant held to a scheduled end running when it reloads a page that
14
+ changes only on reload. 20 reloads in a row on an unchanged screen ended its session as `gave_up`.
15
+ Now humanish watches the screen after each further reload, as after a wait, so a participant that
16
+ reloads every few seconds costs about what one that waits costs. In 60-minute live runs with a
17
+ persona that refreshes every few seconds, 0.133.1 ended 3 of 4 within 7 minutes; this release ran
18
+ all 4 to their end at a median $3.23 an hour. Trace files are now written as compact JSON, about a
19
+ quarter smaller, and a run whose trace still passes the 32 MiB read limit finishes and grades
20
+ `local_only` where it failed.
21
+
22
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.134.0)
23
+
11
24
  ## 0.133.1: Hosted runs keep writing run.json past 512 MiB of traces (2026-10-14)
12
25
 
13
26
  humanish 0.133.1 keeps a hosted run writing run.json once its finished participants' traces
@@ -21,6 +21,8 @@ export interface ActionBatch {
21
21
  * stopped the batch.
22
22
  */
23
23
  readonly hint: string | undefined;
24
+ /** An action of the batch other than a wait or screenshot may have reached the desktop. */
25
+ readonly sent: boolean;
24
26
  }
25
27
  /**
26
28
  * The waits of a turn the loop watched in place of sending them to the desktop (watch.ts): the
@@ -128,6 +128,12 @@ export async function runActionBatch(session, actions, watched) {
128
128
  const { actionHistory, trace } = session;
129
129
  const notes = [];
130
130
  const hint = () => (notes.length === 0 ? undefined : notes.join(" "));
131
+ const attemptsBefore = trace.counts.materialActions;
132
+ const done = () => ({
133
+ execution,
134
+ hint: hint(),
135
+ sent: trace.counts.materialActions > attemptsBefore,
136
+ });
131
137
  const idleTurn = isIdleTurn(actions);
132
138
  let waits = 0;
133
139
  for (const [index, requested] of actions.entries()) {
@@ -187,13 +193,13 @@ export async function runActionBatch(session, actions, watched) {
187
193
  const { reason } = error;
188
194
  trace.record("notice", () => notice("warn", "action rejected before dispatch", session.redactNarration(`action: ${title}; code: action_rejected; disposition: not_dispatched;${reason === undefined ? "" : ` reason: ${reason};`} remaining batch actions not dispatched: ${actions.length - index - 1}`)));
189
195
  notes.push(rejectedActionHint(title, reason));
190
- return { execution, hint: hint() };
196
+ return done();
191
197
  }
192
198
  if (isComputerUseExecutorError(error) || !isCommandExitError(error))
193
199
  throw error;
194
- // A skipped action changes nothing on screen, so a persistently-failing run makes no
195
- // progress and still terminates via the idle/no-progress backstop (gave_up),
196
- // never a silent actor_error and never an infinite loop.
200
+ // A skipped action changes nothing on screen, so a run that repeats a failing action makes
201
+ // no progress and ends through the no-progress backstop (gave_up), also when the
202
+ // participant is held to its scheduled end (backstop.ts consecutiveUnsent).
197
203
  recordCommandFailure(session, title, error);
198
204
  continue;
199
205
  }
@@ -212,7 +218,7 @@ export async function runActionBatch(session, actions, watched) {
212
218
  ...(requested.kind === "wait" && requested.ms !== undefined ? { namedMs: requested.ms } : {}),
213
219
  }));
214
220
  }
215
- return { execution, hint: hint() };
221
+ return done();
216
222
  }
217
223
  function countAttempt(session, action, title, status) {
218
224
  if (!isMaterialAttempt(action, status))
@@ -24,9 +24,27 @@ export interface Backstop {
24
24
  readonly recentFingerprints: readonly string[];
25
25
  readonly consecutiveIdle: number;
26
26
  readonly consecutiveNoProgress: number;
27
+ /**
28
+ * No-progress turns in a row whose actions all failed or were refused before reaching the
29
+ * desktop. They end a held participant's session at noProgressSteps, since a reload that never
30
+ * reached the desktop is not how it waits; one such turn in a longer streak does not.
31
+ */
32
+ readonly consecutiveUnsent: number;
27
33
  readonly idleProgressForgivenessUsed: number;
34
+ /** A no-progress streak in this session reached noProgressSteps. */
35
+ readonly reachedLimit: boolean;
28
36
  }
29
37
  export declare function startBackstop(observation: CuaObservation): Backstop;
38
+ /**
39
+ * Whether a turn that acts repeats a recent action past the no-progress limit: the streak before it
40
+ * is one short of the limit or longer, or, once a streak in the session reached the limit, the turn
41
+ * before it was a no-progress turn. The loop watches the screen after such a turn of a held
42
+ * participant, since its streak no longer ends its session. A change a reload brings, or an item
43
+ * the participant opens, resets the streak; without the second case each one would give the
44
+ * participant noProgressSteps more requests back to back. A repeat after a turn that changed the
45
+ * screen, such as a second scroll, is not past the limit.
46
+ */
47
+ export declare function repeatsPastLimit(backstop: Backstop, actions: readonly CuaAction[], limits: BackstopLimits): boolean;
30
48
  export interface BackstopLimits {
31
49
  readonly idleSteps: number;
32
50
  readonly noProgressSteps: number;
@@ -43,8 +61,9 @@ export interface BackstopStep {
43
61
  /**
44
62
  * Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
45
63
  * participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
46
- * toward neither streak and earns no nudge. A turn that acts is folded as usual. `screenChanged`
47
- * says the turn's watch woke on a change it saw, which the progress key can miss.
64
+ * toward neither streak and earns no nudge, and the no-progress streak does not end the session.
65
+ * A turn that acts is otherwise folded as usual. `screenChanged` says the turn's watch woke on a
66
+ * change it saw, which the progress key can miss.
48
67
  */
49
68
  export declare function advanceBackstop(previous: Backstop, turn: {
50
69
  actions: readonly CuaAction[];
@@ -52,4 +71,6 @@ export declare function advanceBackstop(previous: Backstop, turn: {
52
71
  heardNewSpeech: boolean;
53
72
  waitingIsTheTask?: boolean;
54
73
  screenChanged?: boolean;
74
+ /** An action of the turn may have reached the desktop (ActionBatch.sent). */
75
+ sent: boolean;
55
76
  }, limits: BackstopLimits): BackstopStep;
@@ -119,14 +119,38 @@ export function startBackstop(observation) {
119
119
  recentFingerprints: [],
120
120
  consecutiveIdle: 0,
121
121
  consecutiveNoProgress: 0,
122
+ consecutiveUnsent: 0,
122
123
  idleProgressForgivenessUsed: 0,
124
+ reachedLimit: false,
123
125
  };
124
126
  }
127
+ function repeatsRecentAction(backstop, actions) {
128
+ const fingerprint = actionFingerprint(actions);
129
+ return fingerprint.length > 0 && backstop.recentFingerprints.includes(fingerprint);
130
+ }
131
+ /**
132
+ * Whether a turn that acts repeats a recent action past the no-progress limit: the streak before it
133
+ * is one short of the limit or longer, or, once a streak in the session reached the limit, the turn
134
+ * before it was a no-progress turn. The loop watches the screen after such a turn of a held
135
+ * participant, since its streak no longer ends its session. A change a reload brings, or an item
136
+ * the participant opens, resets the streak; without the second case each one would give the
137
+ * participant noProgressSteps more requests back to back. A repeat after a turn that changed the
138
+ * screen, such as a second scroll, is not past the limit.
139
+ */
140
+ export function repeatsPastLimit(backstop, actions, limits) {
141
+ const limit = backstop.reachedLimit
142
+ ? Math.min(2, limits.noProgressSteps)
143
+ : limits.noProgressSteps;
144
+ return (!isIdleTurn(actions) &&
145
+ backstop.consecutiveNoProgress + 1 >= limit &&
146
+ repeatsRecentAction(backstop, actions));
147
+ }
125
148
  /**
126
149
  * Fold one acted turn and the observation after it into the backstop. `waitingIsTheTask` says the
127
150
  * participant has a scheduled end still to come: waiting for it is its job, so an idle turn counts
128
- * toward neither streak and earns no nudge. A turn that acts is folded as usual. `screenChanged`
129
- * says the turn's watch woke on a change it saw, which the progress key can miss.
151
+ * toward neither streak and earns no nudge, and the no-progress streak does not end the session.
152
+ * A turn that acts is otherwise folded as usual. `screenChanged` says the turn's watch woke on a
153
+ * change it saw, which the progress key can miss.
130
154
  */
131
155
  export function advanceBackstop(previous, turn, limits) {
132
156
  const idle = isIdleTurn(turn.actions);
@@ -151,9 +175,11 @@ export function advanceBackstop(previous, turn, limits) {
151
175
  // just tried. An agent doing varied work is never counted stuck, however blind the hash is;
152
176
  // an agent re-clicking the same dead control trips it as fast as it did before, arguably
153
177
  // faster, since that is the actual signature of being stuck.
154
- const fingerprint = actionFingerprint(turn.actions);
155
- const repeatingRecentAction = fingerprint.length > 0 && previous.recentFingerprints.includes(fingerprint);
156
- const recentFingerprints = [...previous.recentFingerprints, fingerprint].slice(-ACTION_REPEAT_WINDOW);
178
+ const repeatingRecentAction = repeatsRecentAction(previous, turn.actions);
179
+ const recentFingerprints = [
180
+ ...previous.recentFingerprints,
181
+ actionFingerprint(turn.actions),
182
+ ].slice(-ACTION_REPEAT_WINDOW);
157
183
  // Corroboration governs the frame-staleness backstop only. The idle backstop below is a direct
158
184
  // behavioral signal already (the agent took nothing but screenshots and waits), so it keeps
159
185
  // reading the frame on its own: a repeated screenshot is exactly what an idle streak is, and
@@ -177,6 +203,7 @@ export function advanceBackstop(previous, turn, limits) {
177
203
  consecutiveIdle += 1;
178
204
  }
179
205
  const consecutiveNoProgress = progressed ? 0 : previous.consecutiveNoProgress + 1;
206
+ const consecutiveUnsent = progressed || turn.sent ? 0 : previous.consecutiveUnsent + 1;
180
207
  // An unchanged screen can be legitimate waiting (for example, a shared-world lobby).
181
208
  // Recovery may suggest another approach, but must not instruct early abandonment while
182
209
  // the task still calls for waiting. The counters, time and spend guards own hard stops.
@@ -196,10 +223,14 @@ export function advanceBackstop(previous, turn, limits) {
196
223
  "If your task calls for waiting, you may continue within the remaining session time. " +
197
224
  "When the relevant controls become actionable, continue your task; describe any blocker you actually encounter.");
198
225
  }
226
+ // A held participant past the no-progress limit keeps its session: on a page that shows new items
227
+ // only after a reload, reloading an unchanged screen is how it waits. Repeats that never reached
228
+ // the desktop still end it.
229
+ const stuckTurns = turn.waitingIsTheTask === true ? consecutiveUnsent : consecutiveNoProgress;
199
230
  const tripReason = consecutiveIdle >= limits.idleSteps
200
231
  ? `gave up: ${consecutiveIdle} consecutive turns with no material UI action (only screenshot/wait)`
201
- : consecutiveNoProgress >= limits.noProgressSteps
202
- ? `gave up: ${consecutiveNoProgress} consecutive turns that repeated a recent action on an unchanged UI state`
232
+ : stuckTurns >= limits.noProgressSteps
233
+ ? `gave up: ${stuckTurns} consecutive turns that repeated a recent action on an unchanged UI state`
203
234
  : undefined;
204
235
  return {
205
236
  backstop: {
@@ -207,7 +238,9 @@ export function advanceBackstop(previous, turn, limits) {
207
238
  recentFingerprints,
208
239
  consecutiveIdle,
209
240
  consecutiveNoProgress,
241
+ consecutiveUnsent,
210
242
  idleProgressForgivenessUsed,
243
+ reachedLimit: previous.reachedLimit || consecutiveNoProgress >= limits.noProgressSteps,
211
244
  },
212
245
  idle,
213
246
  progressed,
@@ -2,6 +2,7 @@ import type { ActorTraceItem, ParticipantDeclaredOutcome, ParticipantImpressions
2
2
  import type { AffordanceObservation } from "../../affordance.js";
3
3
  import { TaskTracker } from "../../../study/tasks.js";
4
4
  import type { DebriefTrigger, Stop } from "./ending.js";
5
+ import { StreakRecord } from "./streak.js";
5
6
  import { TraceRecorder } from "./trace.js";
6
7
  import type { CuaExecutor, CuaProvider, CuaSafetyCheck, LoopRunOptions } from "./types.js";
7
8
  import { UsageLedger } from "./usage.js";
@@ -55,6 +56,8 @@ export declare class LoopSession {
55
56
  readonly trace: TraceRecorder;
56
57
  readonly usage: UsageLedger;
57
58
  readonly taskTracker: TaskTracker | undefined;
59
+ /** The no-progress streak a held participant is in, for the trace. */
60
+ readonly streak: StreakRecord;
58
61
  readonly actionHistory: ActionHistory;
59
62
  /** What the loop was doing, for failure notices. */
60
63
  phase: string;
@@ -1,4 +1,5 @@
1
1
  import { TaskTracker } from "../../../study/tasks.js";
2
+ import { StreakRecord } from "./streak.js";
2
3
  import { TraceRecorder } from "./trace.js";
3
4
  import { UsageLedger } from "./usage.js";
4
5
  import { resolveWaitSettings } from "../wait.js";
@@ -47,6 +48,8 @@ export class LoopSession {
47
48
  usage;
48
49
  // The funnel is recorded, never consulted: task completion does not steer the loop.
49
50
  taskTracker;
51
+ /** The no-progress streak a held participant is in, for the trace. */
52
+ streak = new StreakRecord(this);
50
53
  actionHistory = {
51
54
  lastActionTitle: undefined,
52
55
  lastMaterialActionTitle: undefined,
@@ -0,0 +1,18 @@
1
+ import type { LoopSession } from "./session.js";
2
+ /** The title of the notice that records one streak. */
3
+ export declare const STREAK_TITLE = "repeated an action on an unchanged screen";
4
+ /** The streak the participant is in, recorded when a turn ends it or the session ends. */
5
+ export declare class StreakRecord {
6
+ private readonly session;
7
+ private open;
8
+ constructor(session: LoopSession);
9
+ /**
10
+ * Fold one turn's streak length, the backstop's consecutiveNoProgress after the turn. A held
11
+ * participant's idle turn leaves the length as it was, and the streak's time span with it. Only
12
+ * the streak of a participant with a scheduled end is followed: any other one ends the session
13
+ * at noProgressSteps. The end may have passed, since the turn that reaches it is still counted.
14
+ */
15
+ fold(turns: number): void;
16
+ /** Record the open streak when it reached noProgressSteps. */
17
+ close(): void;
18
+ }
@@ -0,0 +1,47 @@
1
+ import { notice } from "./trace.js";
2
+ // The record of a held participant's no-progress streaks. Past noProgressSteps a streak no longer
3
+ // ends the session of a participant held to its scheduled end (backstop.ts), so the trace says how
4
+ // many turns it had and when: analysis reads the notice as friction, and the review lists it.
5
+ /** The title of the notice that records one streak. */
6
+ export const STREAK_TITLE = "repeated an action on an unchanged screen";
7
+ /** The streak the participant is in, recorded when a turn ends it or the session ends. */
8
+ export class StreakRecord {
9
+ session;
10
+ open;
11
+ constructor(session) {
12
+ this.session = session;
13
+ }
14
+ /**
15
+ * Fold one turn's streak length, the backstop's consecutiveNoProgress after the turn. A held
16
+ * participant's idle turn leaves the length as it was, and the streak's time span with it. Only
17
+ * the streak of a participant with a scheduled end is followed: any other one ends the session
18
+ * at noProgressSteps. The end may have passed, since the turn that reaches it is still counted.
19
+ */
20
+ fold(turns) {
21
+ const { open, session } = this;
22
+ const end = session.settings.clock?.end;
23
+ if (turns === 0)
24
+ this.close();
25
+ else if (open?.turns !== turns && end !== undefined && end.kind !== "session_cap") {
26
+ const now = session.now();
27
+ this.open = { turns, fromMs: open?.fromMs ?? now, toMs: now };
28
+ }
29
+ }
30
+ /** Record the open streak when it reached noProgressSteps. */
31
+ close() {
32
+ const { open, session } = this;
33
+ this.open = undefined;
34
+ if (open === undefined || open.turns < session.noProgressSteps)
35
+ return;
36
+ const at = (ms) => sessionClock(ms - session.startedAtMs);
37
+ session.trace.record("notice", () => notice("warn", STREAK_TITLE, `${open.turns} turns in a row repeated a recent action on an unchanged screen, from ${at(open.fromMs)} to ${at(open.toMs)} into the session. The participant is held to its scheduled end, so ${session.noProgressSteps} such turns did not end its session, and from then on the screen was watched after each one before the next request.`));
38
+ }
39
+ }
40
+ /** `40:35`, or `1:02:03` from an hour. */
41
+ function sessionClock(ms) {
42
+ const seconds = Math.floor(Math.max(0, ms) / 1_000);
43
+ const minutes = Math.floor(seconds / 60);
44
+ const hours = Math.floor(minutes / 60);
45
+ const ss = String(seconds % 60).padStart(2, "0");
46
+ return hours > 0 ? `${hours}:${String(minutes % 60).padStart(2, "0")}:${ss}` : `${minutes}:${ss}`;
47
+ }
@@ -312,7 +312,11 @@ export interface CuaLoopOptions {
312
312
  signal?: AbortSignal;
313
313
  /** Idle streak (no material action) that trips the backstop. Default 24. */
314
314
  idleSteps?: number;
315
- /** No-progress streak that trips the backstop. Default 20. */
315
+ /**
316
+ * No-progress streak that trips the backstop. Default 20. It does not end a participant held to
317
+ * its scheduled end: from this many repeats on, the screen is watched after each further one that
318
+ * follows a turn with no progress.
319
+ */
316
320
  noProgressSteps?: number;
317
321
  /**
318
322
  * The longest one `wait` action lasts, in ms: the study's actor.maxWaitMs. In a turn with another
@@ -4,7 +4,7 @@ import { type Stop } from "./ending.js";
4
4
  import type { DesktopObserver } from "./observation.js";
5
5
  import type { LoopSession } from "./session.js";
6
6
  import type { CuaAction, CuaObservation } from "./types.js";
7
- /** A watch that ran for one idle turn: how it ended, and what the loop does next. */
7
+ /** A watch that ran for one turn: how it ended, and what the loop does next. */
8
8
  export interface Watched {
9
9
  readonly wake: ActorWatch["wake"];
10
10
  /**
@@ -23,20 +23,38 @@ export interface Watched {
23
23
  readonly waits: WatchedWaits;
24
24
  }
25
25
  /**
26
- * The watches of one participant's idle turns. Between turns it keeps the run of idle turns the
26
+ * The watches of one participant's turns. Between turns it keeps the run of watched turns the
27
27
  * doubling counts: a watch that ended at its limit or on a small change makes the next one in the
28
- * run longer, and a turn with another action ends the run.
28
+ * run longer, and a turn with another action that is not watched ends the run.
29
29
  */
30
30
  export declare class IdleWatcher {
31
31
  private readonly session;
32
32
  private readonly observer;
33
33
  /** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
34
34
  private grownFromMs;
35
+ /** The last turn only waited or took screenshots. */
36
+ private quiet;
35
37
  constructor(session: LoopSession, observer: DesktopObserver);
36
38
  /**
37
39
  * Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
38
40
  * another action, which ends the run, or with no wait time (screenshots only, or waits of 0 ms),
39
- * which leaves the run as it is. `reference` is the observation the participant chose to wait on.
41
+ * which leaves the run as it is. A participant held to its scheduled end is watched for a turn
42
+ * with no wait time after another turn that only waited or took screenshots, as for a wait with
43
+ * no length: it waits for its end, and without the watch it would send its requests back to back.
44
+ * The first such turn, at the start or after an action, is sent at once, so a participant that
45
+ * looks again after acting does not wait idleWaitMs for it. `reference` is the observation the
46
+ * participant chose to wait on.
40
47
  */
41
48
  watch(turnNumber: number, reference: CuaObservation, actions: readonly CuaAction[]): Promise<Watched | undefined>;
49
+ /**
50
+ * Watch the screen after a held participant's turn that repeated a recent action past its
51
+ * no-progress limit (repeatsPastLimit), as after a wait with no length, before its next request.
52
+ * The run goes on, so a participant that reloads an unchanged page turn after turn waits as long
53
+ * as one that only waits. `reference` is the observation the participant acted on, so a change
54
+ * its action made wakes it. Undefined, with nothing run, when no action of the turn reached the
55
+ * desktop (`sent`, ActionBatch.sent): such a repeat is not how the participant waits, and
56
+ * noProgressSteps of them end its session (backstop.ts consecutiveUnsent).
57
+ */
58
+ watchAfterRepeat(turnNumber: number, reference: CuaObservation, sent: boolean): Promise<Watched | undefined>;
59
+ private run;
42
60
  }
@@ -2,20 +2,22 @@ import { watchFrames, } from "../../../evidence/frame-change.js";
2
2
  import { plural } from "../../../run/text.js";
3
3
  import { isIdleTurn } from "./actions.js";
4
4
  import { progressKeyOf } from "./backstop.js";
5
- import { clockEndStop } from "./clock.js";
5
+ import { clockEndStop, heldToScheduledEnd } from "./clock.js";
6
6
  import { timeLimit } from "./ending.js";
7
7
  import { CuaAbortError, CuaStallError, raceSessionDeadline } from "./race.js";
8
8
  import { spendStop } from "./spend.js";
9
9
  /**
10
- * The watches of one participant's idle turns. Between turns it keeps the run of idle turns the
10
+ * The watches of one participant's turns. Between turns it keeps the run of watched turns the
11
11
  * doubling counts: a watch that ended at its limit or on a small change makes the next one in the
12
- * run longer, and a turn with another action ends the run.
12
+ * run longer, and a turn with another action that is not watched ends the run.
13
13
  */
14
14
  export class IdleWatcher {
15
15
  session;
16
16
  observer;
17
17
  /** The limit of the run's last watch, while that watch ended at its limit or on a small change. */
18
18
  grownFromMs;
19
+ /** The last turn only waited or took screenshots. */
20
+ quiet = false;
19
21
  constructor(session, observer) {
20
22
  this.session = session;
21
23
  this.observer = observer;
@@ -23,11 +25,18 @@ export class IdleWatcher {
23
25
  /**
24
26
  * Watch the screen in place of this turn's waits. Undefined, with nothing run, for a turn with
25
27
  * another action, which ends the run, or with no wait time (screenshots only, or waits of 0 ms),
26
- * which leaves the run as it is. `reference` is the observation the participant chose to wait on.
28
+ * which leaves the run as it is. A participant held to its scheduled end is watched for a turn
29
+ * with no wait time after another turn that only waited or took screenshots, as for a wait with
30
+ * no length: it waits for its end, and without the watch it would send its requests back to back.
31
+ * The first such turn, at the start or after an action, is sent at once, so a participant that
32
+ * looks again after acting does not wait idleWaitMs for it. `reference` is the observation the
33
+ * participant chose to wait on.
27
34
  */
28
35
  async watch(turnNumber, reference, actions) {
29
36
  const { session } = this;
30
- if (!isIdleTurn(actions)) {
37
+ const quietBefore = this.quiet;
38
+ this.quiet = isIdleTurn(actions);
39
+ if (!this.quiet) {
31
40
  this.grownFromMs = undefined;
32
41
  return undefined;
33
42
  }
@@ -46,9 +55,43 @@ export class IdleWatcher {
46
55
  namedMs += ms;
47
56
  return [ms];
48
57
  });
49
- const plannedMs = lengths.reduce((sum, ms) => sum + ms, 0);
50
- if (plannedMs === 0)
58
+ if (lengths.every((ms) => ms === 0) && !(quietBefore && heldToScheduledEnd(session)))
59
+ return undefined;
60
+ return this.run(turnNumber, reference, {
61
+ lengths,
62
+ askedMs: asked.reduce((sum, ms) => sum + ms, 0),
63
+ ...(lengths.length > 0
64
+ ? { lead: "Your wait" }
65
+ : {
66
+ lead: "The wait before your screenshot",
67
+ unasked: "The turn took only screenshots after a turn of only waits or screenshots, and the participant is held to its scheduled end, so the screen was watched as for a wait with no length.",
68
+ }),
69
+ });
70
+ }
71
+ /**
72
+ * Watch the screen after a held participant's turn that repeated a recent action past its
73
+ * no-progress limit (repeatsPastLimit), as after a wait with no length, before its next request.
74
+ * The run goes on, so a participant that reloads an unchanged page turn after turn waits as long
75
+ * as one that only waits. `reference` is the observation the participant acted on, so a change
76
+ * its action made wakes it. Undefined, with nothing run, when no action of the turn reached the
77
+ * desktop (`sent`, ActionBatch.sent): such a repeat is not how the participant waits, and
78
+ * noProgressSteps of them end its session (backstop.ts consecutiveUnsent).
79
+ */
80
+ async watchAfterRepeat(turnNumber, reference, sent) {
81
+ this.quiet = false;
82
+ if (!sent)
51
83
  return undefined;
84
+ return this.run(turnNumber, reference, {
85
+ lengths: [],
86
+ askedMs: 0,
87
+ lead: "The wait after your last action",
88
+ unasked: "The turn repeated a recent action on an unchanged screen past the no-progress limit and the participant is held to its scheduled end, so the screen was watched before the next request.",
89
+ });
90
+ }
91
+ async run(turnNumber, reference, asked) {
92
+ const { session } = this;
93
+ const { lengths, unasked } = asked;
94
+ const plannedMs = lengths.reduce((sum, ms) => sum + ms, 0);
52
95
  const capMs = Math.max(plannedMs, session.waits.watchMaxMs);
53
96
  // Never shorter than idleWaitMs, so a participant naming a few milliseconds on a static screen
54
97
  // does not send requests back to back, and never shorter than the turn's own planned length, so
@@ -63,21 +106,29 @@ export class IdleWatcher {
63
106
  // watch short.
64
107
  const grows = ending.wake === "limit" || (ending.seen?.verdict === "settled" && ending.seen.small);
65
108
  this.grownFromMs = grows ? limitMs : undefined;
109
+ const watch = {
110
+ limitMs,
111
+ askedMs: asked.askedMs,
112
+ wake: ending.wake,
113
+ looks: ending.looks,
114
+ ...(ending.area === undefined ? {} : { area: { ...ending.area } }),
115
+ };
116
+ if (unasked !== undefined)
117
+ session.trace.record("notice", () => ({
118
+ lifecycle: "completed",
119
+ title: "watched the screen",
120
+ text: unasked,
121
+ watch: { ...watch },
122
+ }));
66
123
  return {
67
124
  wake: ending.wake,
68
125
  ...(ending.observation === undefined ? {} : { observation: ending.observation }),
69
126
  ...(ending.stop === undefined ? {} : { stop: ending.stop }),
70
- hint: hintFor(ending),
127
+ hint: hintFor(ending, asked.lead),
71
128
  waits: {
72
129
  ...splitWatch(lengths, limitMs, ending.ms),
73
130
  sessionEnded: ending.stop !== undefined,
74
- watch: {
75
- limitMs,
76
- askedMs: asked.reduce((sum, ms) => sum + ms, 0),
77
- wake: ending.wake,
78
- looks: ending.looks,
79
- ...(ending.area === undefined ? {} : { area: { ...ending.area } }),
80
- },
131
+ watch,
81
132
  },
82
133
  };
83
134
  }
@@ -198,17 +249,21 @@ function compareLook(frames, referenceKey, observation, atMs) {
198
249
  }
199
250
  /**
200
251
  * Each wait's part of a watch that lasted `ms`: the limit is shared among the waits in proportion
201
- * to their planned lengths, and the watch covers them in order. Later waits get 0.
252
+ * to their planned lengths, and the watch covers them in order. Later waits get 0. Waits that all
253
+ * planned 0 ms (a held participant's, watched as a wait with no length) give the first all of it.
202
254
  */
203
- function splitWatch(lengths, limitMs, ms) {
255
+ function splitWatch(planned, limitMs, ms) {
256
+ const lengths = planned.every((length) => length === 0)
257
+ ? planned.map((_, index) => (index === 0 ? 1 : 0))
258
+ : planned;
204
259
  const plannedMs = lengths.reduce((sum, length) => sum + length, 0);
205
260
  const starts = [];
206
261
  const ends = [];
207
- let planned = 0;
262
+ let sharedMs = 0;
208
263
  for (const length of lengths) {
209
- starts.push(Math.floor((limitMs * planned) / plannedMs));
210
- planned += length;
211
- ends.push(Math.floor((limitMs * planned) / plannedMs));
264
+ starts.push(Math.floor((limitMs * sharedMs) / plannedMs));
265
+ sharedMs += length;
266
+ ends.push(Math.floor((limitMs * sharedMs) / plannedMs));
212
267
  }
213
268
  const found = ends.findIndex((shareEnd, index) => shareEnd > starts[index] && ms <= shareEnd);
214
269
  const endedOn = found === -1 ? lengths.length - 1 : found;
@@ -218,13 +273,14 @@ function splitWatch(lengths, limitMs, ms) {
218
273
  };
219
274
  }
220
275
  /**
221
- * The next request's note. A clock ending says only how long the wait lasted. A
222
- * small change is named by its place, so a participant is not told the screen changed when what
223
- * changed was a counter or a timestamp. The note says nothing of the next wait's length: a
224
- * participant told it names that length and keeps waiting.
276
+ * The next request's note, its subject `lead` naming the wait ("Your wait", or for a watch no wait
277
+ * asked for, the wait before the screenshot or after the action). A clock ending says only how
278
+ * long the wait lasted. A small change is named by its place, so a participant is not told the
279
+ * screen changed when what changed was a counter or a timestamp. The note says nothing of the next
280
+ * wait's length: a participant told it names that length and keeps waiting.
225
281
  */
226
- function hintFor(ending) {
227
- const lasted = `Your wait lasted ${duration(ending.ms)}`;
282
+ function hintFor(ending, lead) {
283
+ const lasted = `${lead} lasted ${duration(ending.ms)}`;
228
284
  const { seen } = ending;
229
285
  switch (ending.wake) {
230
286
  case "changed":
@@ -1,7 +1,7 @@
1
1
  import { closingReportSchema } from "../closing-report.js";
2
2
  import { ComputerUseProviderError } from "./provider-error.js";
3
3
  import { runActionBatch } from "./loop/actions.js";
4
- import { advanceBackstop, startBackstop, } from "./loop/backstop.js";
4
+ import { advanceBackstop, repeatsPastLimit, startBackstop, } from "./loop/backstop.js";
5
5
  import { clockEndStop, clockHint, heldToScheduledEnd } from "./loop/clock.js";
6
6
  import { requestDebrief } from "./loop/debrief.js";
7
7
  import * as stops from "./loop/ending.js";
@@ -62,6 +62,7 @@ export async function runComputerUseLoopWithTaps(options) {
62
62
  catch (error) {
63
63
  stop = session.conclude(stops.stopForError(session, error));
64
64
  }
65
+ session.streak.close();
65
66
  // A structured stop earns the debrief even if recording its evidence then failed.
66
67
  const debrief = session.debriefTrigger === undefined
67
68
  ? undefined
@@ -144,12 +145,21 @@ async function runTurns(session, conversation) {
144
145
  ? { ...ended, debriefTrigger: { kind: "participant_end", observation } }
145
146
  : ended;
146
147
  }
147
- const watched = await watcher.watch(turnNumber, observation, turn.actions);
148
- const batch = await runActionBatch(session, turn.actions, watched?.waits);
148
+ const limits = { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps };
149
+ const repeat = repeatsPastLimit(backstop, turn.actions, limits) && heldToScheduledEnd(session);
150
+ const waited = repeat ? undefined : await watcher.watch(turnNumber, observation, turn.actions);
151
+ const batch = await runActionBatch(session, turn.actions, waited?.waits);
152
+ const watched = repeat
153
+ ? await watcher.watchAfterRepeat(turnNumber, observation, batch.sent)
154
+ : waited;
149
155
  conversation.previousExecution = batch.execution;
150
156
  // An earlier reply's unknown usage still stops the session before its closing request.
151
- if (watched?.stop !== undefined)
157
+ if (watched?.stop !== undefined) {
158
+ // The repeat was sent before its watch ended the session, so its streak counts it.
159
+ if (repeat)
160
+ session.streak.fold(backstop.consecutiveNoProgress + 1);
152
161
  return unknownSpendStop(session) ?? watched.stop;
162
+ }
153
163
  const checkpoint = await observer.checkpoint(turnNumber, watched?.observation);
154
164
  if ("stop" in checkpoint)
155
165
  return checkpoint.stop;
@@ -158,11 +168,12 @@ async function runTurns(session, conversation) {
158
168
  actions: turn.actions,
159
169
  observation,
160
170
  heardNewSpeech: observer.heardNewSpeech,
171
+ sent: batch.sent,
161
172
  waitingIsTheTask: heldToScheduledEnd(session),
162
173
  screenChanged: watched?.wake === "changed" ||
163
174
  watched?.wake === "speech" ||
164
175
  watched?.wake === "condition",
165
- }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
176
+ }, limits);
166
177
  backstop = step.backstop;
167
178
  const stalled = applyBackstop(session, conversation, step, [
168
179
  watched?.hint,
@@ -187,7 +198,7 @@ function keepWorking(session, conversation, backstop, observation) {
187
198
  conversation.askedToEnd = true;
188
199
  // The turn executed nothing, so the next request acknowledges nothing.
189
200
  conversation.previousExecution = undefined;
190
- const step = advanceBackstop(backstop, { actions: [], observation, heardNewSpeech: false }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
201
+ const step = advanceBackstop(backstop, { actions: [], observation, heardNewSpeech: false, sent: false }, { idleSteps: session.idleSteps, noProgressSteps: session.noProgressSteps });
191
202
  const stalled = applyBackstop(session, conversation, step, []);
192
203
  return stalled === undefined ? { backstop: step.backstop } : { stop: stalled };
193
204
  }
@@ -201,6 +212,7 @@ function applyBackstop(session, conversation, step, turnHints) {
201
212
  session.trace.bump("idleTurns");
202
213
  if (!step.progressed)
203
214
  session.trace.bump("noProgressTurns");
215
+ session.streak.fold(step.backstop.consecutiveNoProgress);
204
216
  const hints = [...turnHints.filter((hint) => hint !== undefined), ...step.hints];
205
217
  if (hints.length > 0)
206
218
  conversation.contextHint = hints.join(" ");
@@ -88,7 +88,9 @@ export interface ActorTraceItem {
88
88
  * the observation read no URL, on other producers and in older bundles. */
89
89
  page?: TracePage;
90
90
  /** On the computer-use `ui_action` of the wait during which a watch ended: the watch the loop
91
- * ran in place of a turn that only waited. Additive: absent on plain waits, on other items and
91
+ * ran in place of a turn that only waited. A watch no wait carries, which a participant held to
92
+ * its scheduled end gets for a turn of only screenshots or after a repeated action, is on a
93
+ * `notice` titled `watched the screen`. Additive: absent on plain waits, on other items and
92
94
  * producers, and in older bundles. */
93
95
  watch?: ActorWatch;
94
96
  /** On a computer-use wait's `ui_action`: the length in milliseconds the participant named for