@basein/runner 0.3.14 → 0.3.15

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.
@@ -1075,21 +1075,36 @@ export declare class ControlServer {
1075
1075
  private applyFragmentCreation;
1076
1076
  /**
1077
1077
  * Whether this matched turn's task is now the model's own, so its work is
1078
- * the repeat the service offered to record (the service's newestRun.md N1):
1079
- * offered, nothing recording, no fragment, and no plan of the prompt match
1080
- * that ran a step or still may. A plan that handed over is the fragment's to
1081
- * record (fallbk.md D7).
1078
+ * the repeat the service offered to record: offered, nothing recording, no
1079
+ * fragment, and the prompt match's plan done with — retired, not running,
1080
+ * not diverging — and either:
1081
+ *
1082
+ * - it ran no step: the model does the whole task (newestRun.md N1); or
1083
+ * - it ran to its end, and the model, told to answer from its results,
1084
+ * called tools of its own instead (N7). Before 0.3.15 that work was
1085
+ * lost: a model that did not take the plan's result as the answer and
1086
+ * did the task again left nothing to fix the plan from.
1087
+ *
1088
+ * A plan that handed over is the fragment's to record (fallbk.md D7). One
1089
+ * that ran part of the task and stopped without a hand-over records
1090
+ * nothing, as before: the call where it stopped ran while it was still
1091
+ * live, so a run of what came after would lack the step that matters.
1082
1092
  */
1083
1093
  private repeatDue;
1084
1094
  /**
1085
- * Open the repeat run and record from this call on (newestRun.md N1).
1095
+ * Whether the prompt match's plan ran to its end before the model went on
1096
+ * by itself (newestRun.md N7). False when it ran no step, which is N1.
1097
+ */
1098
+ private redoneAfterPlan;
1099
+ /**
1100
+ * Open the repeat run and record from this call on (newestRun.md N1, N7).
1086
1101
  *
1087
1102
  * The same swap as {@link openFragment}: nothing allocated before it belongs
1088
- * to it. Unlike a fragment it carries the turn's prompt, and its cost and
1089
- * duration are the whole turn's, because no plan did any of the task. Nothing
1090
- * waits for the service's answer: the steps queue behind the create, and a
1091
- * create answered `id: null` — the service records no repeat of it — drops
1092
- * them.
1103
+ * to it. Unlike a fragment it carries the turn's prompt. When no plan did
1104
+ * any of the task, its cost and duration are the whole turn's; after a plan
1105
+ * that ran to its end, they start here, as a fragment's do. Nothing waits
1106
+ * for the service's answer: the steps queue behind the create, and a create
1107
+ * answered `id: null` — the service records no repeat of it — drops them.
1093
1108
  */
1094
1109
  private openRepeat;
1095
1110
  /**
@@ -2292,12 +2292,15 @@ export class ControlServer {
2292
2292
  this.reportExecution(session, run, durationMs, cost, cutShort);
2293
2293
  // A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
2294
2294
  // and duration are the model's own work, not the steps the scenario did.
2295
- // Another host hands over one number for the whole run, and it is the
2296
- // fragment's: the plan's own steps made no model call, so all of it was
2297
- // spent after the hand-over.
2295
+ // So is a repeat opened after the plan ran to its end (newestRun.md N7). Another host
2296
+ // hands over one number for the whole run, and it is the fragment's: the
2297
+ // plan's own steps made no model call, so all of it was spent after the
2298
+ // hand-over.
2298
2299
  const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
2299
- const recordedCost = fragment && !other ? this.costSince(session, fragment.usageMark) : cost;
2300
- const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
2300
+ const repeatAfterPlan = !fragment && run.repeat?.state === "open" && run.repeat.startedAtMs !== undefined ? run.repeat : undefined;
2301
+ const ownWork = fragment ?? repeatAfterPlan;
2302
+ const recordedCost = ownWork && !other ? this.costSince(session, ownWork.usageMark) : cost;
2303
+ const recordedMs = ownWork?.startedAtMs ? Date.now() - ownWork.startedAtMs : durationMs;
2301
2304
  if (run.recording) {
2302
2305
  const transcriptPath = session.transcriptPath;
2303
2306
  // The answer is the host's when it handed one over, and the
@@ -4032,10 +4035,20 @@ export class ControlServer {
4032
4035
  }
4033
4036
  /**
4034
4037
  * Whether this matched turn's task is now the model's own, so its work is
4035
- * the repeat the service offered to record (the service's newestRun.md N1):
4036
- * offered, nothing recording, no fragment, and no plan of the prompt match
4037
- * that ran a step or still may. A plan that handed over is the fragment's to
4038
- * record (fallbk.md D7).
4038
+ * the repeat the service offered to record: offered, nothing recording, no
4039
+ * fragment, and the prompt match's plan done with — retired, not running,
4040
+ * not diverging — and either:
4041
+ *
4042
+ * - it ran no step: the model does the whole task (newestRun.md N1); or
4043
+ * - it ran to its end, and the model, told to answer from its results,
4044
+ * called tools of its own instead (N7). Before 0.3.15 that work was
4045
+ * lost: a model that did not take the plan's result as the answer and
4046
+ * did the task again left nothing to fix the plan from.
4047
+ *
4048
+ * A plan that handed over is the fragment's to record (fallbk.md D7). One
4049
+ * that ran part of the task and stopped without a hand-over records
4050
+ * nothing, as before: the call where it stopped ran while it was still
4051
+ * live, so a run of what came after would lack the step that matters.
4039
4052
  */
4040
4053
  repeatDue(run) {
4041
4054
  const r = run.repeat;
@@ -4044,23 +4057,34 @@ export class ControlServer {
4044
4057
  const prompt = run.replays.find((s) => s.armedBy === "prompt");
4045
4058
  if (!prompt?.plan)
4046
4059
  return true;
4047
- return prompt.retired && prompt.stepsPinned === 0 && !prompt.handover;
4060
+ if (!prompt.retired || prompt.diverging || this.runningPlans.has(prompt) || prompt.handover)
4061
+ return false;
4062
+ return prompt.stepsPinned === 0 || prompt.ranToEnd === true;
4063
+ }
4064
+ /**
4065
+ * Whether the prompt match's plan ran to its end before the model went on
4066
+ * by itself (newestRun.md N7). False when it ran no step, which is N1.
4067
+ */
4068
+ redoneAfterPlan(run) {
4069
+ const prompt = run.replays.find((s) => s.armedBy === "prompt");
4070
+ return prompt?.plan !== undefined && prompt.ranToEnd === true;
4048
4071
  }
4049
4072
  /**
4050
- * Open the repeat run and record from this call on (newestRun.md N1).
4073
+ * Open the repeat run and record from this call on (newestRun.md N1, N7).
4051
4074
  *
4052
4075
  * The same swap as {@link openFragment}: nothing allocated before it belongs
4053
- * to it. Unlike a fragment it carries the turn's prompt, and its cost and
4054
- * duration are the whole turn's, because no plan did any of the task. Nothing
4055
- * waits for the service's answer: the steps queue behind the create, and a
4056
- * create answered `id: null` — the service records no repeat of it — drops
4057
- * them.
4076
+ * to it. Unlike a fragment it carries the turn's prompt. When no plan did
4077
+ * any of the task, its cost and duration are the whole turn's; after a plan
4078
+ * that ran to its end, they start here, as a fragment's do. Nothing waits
4079
+ * for the service's answer: the steps queue behind the create, and a create
4080
+ * answered `id: null` — the service records no repeat of it — drops them.
4058
4081
  */
4059
4082
  openRepeat(session, run) {
4060
4083
  const r = run.repeat;
4061
4084
  if (!r || r.state !== "offered")
4062
4085
  return;
4063
- const reason = this.repeatReason(run);
4086
+ const redone = this.redoneAfterPlan(run);
4087
+ const reason = redone ? "redone" : this.repeatReason(run);
4064
4088
  const repeatOf = reason ? { runId: r.runId, reason } : { runId: r.runId };
4065
4089
  // The repeat is a run of the same host as the turn it records.
4066
4090
  const framework = frameworkOf(session);
@@ -4075,11 +4099,19 @@ export class ControlServer {
4075
4099
  run.ordering = new StepIndexAllocator();
4076
4100
  run.recording = true;
4077
4101
  r.state = "open";
4102
+ if (redone) {
4103
+ // The plan's steps are not the model's work: this run is billed and
4104
+ // timed from its own first call on, as a fragment is.
4105
+ r.usageMark = markTranscriptUsage(session.transcriptPath);
4106
+ r.startedAtMs = Date.now();
4107
+ }
4078
4108
  logLine("run.repeat", {
4079
4109
  run: id,
4080
4110
  of: r.runId,
4081
4111
  reason,
4082
- why: "the agent is doing a recorded task itself — recorded as that recording's newest run",
4112
+ why: redone
4113
+ ? "the plan ran to its end, and the agent went on with calls of its own instead of answering from its results — recorded as that recording's newest run"
4114
+ : "the agent is doing a recorded task itself — recorded as that recording's newest run",
4083
4115
  });
4084
4116
  if (!isRunCreationAware(this.recorder))
4085
4117
  return;
@@ -13,9 +13,12 @@ export interface StartRunOptions {
13
13
  };
14
14
  /**
15
15
  * Marks a *repeat* (the service's newestRun.md): a matched turn whose plan
16
- * did not run, so the model is doing the recording's task itself. Sent only
17
- * when the match offered it ({@link RunMatch.repeat}). `reason` is why the
18
- * plan did not run. The service never prompt-matches a repeat.
16
+ * did not run, so the model is doing the recording's task itself — or whose
17
+ * plan ran to its end, after which the model went on with calls of its own
18
+ * instead of answering from its results (N7). Sent only when the match
19
+ * offered it ({@link RunMatch.repeat}). `reason` is why the plan did not
20
+ * run, or `redone` after a plan that ran to its end. The service never
21
+ * prompt-matches a repeat.
19
22
  */
20
23
  repeatOf?: {
21
24
  runId: string;
@@ -435,6 +435,24 @@ export interface ReplayState {
435
435
  sharedWith: string[];
436
436
  /** Set when the plan handed the task to the model part-way (fallbk.md). */
437
437
  handover?: Handover;
438
+ /**
439
+ * Every planned step ran and the model was handed the results to answer
440
+ * from: a steered plan whose last step threaded, a direct run that ran to
441
+ * its end, or a divergence whose bundle carried the rest of the chain. A
442
+ * call the model makes of its own after this is the task done again — it
443
+ * did not take the plan's result as the answer (the service's newestRun.md
444
+ * N7). Never set on a plan that handed over: the hand-over says the rest
445
+ * is the model's.
446
+ */
447
+ ranToEnd?: boolean;
448
+ /**
449
+ * A divergence is running the plan's remaining steps for real (§8). The
450
+ * plan is already retired by then, so this is what tells a call made beside
451
+ * that work from one made after it: a repeat never opens while steps are
452
+ * still landing, and whether the plan ran to its end or handed over is not
453
+ * known yet (the service's newestRun.md N7).
454
+ */
455
+ diverging?: boolean;
438
456
  /**
439
457
  * Which gate declined, as a fixed code (segmented.md R-HIT-14). The prose
440
458
  * `why` that `decline()` logs stays free text; this is what code reads — the
@@ -1375,6 +1375,7 @@ export class ReplayController {
1375
1375
  }
1376
1376
  if (done) {
1377
1377
  this.upgrade(state, "steered_full");
1378
+ state.ranToEnd = true;
1378
1379
  // A completing retire: the last step threaded and the plan's work is over
1379
1380
  // (segmented.md R-MONEY-3). Everything the agent spends after this is the
1380
1381
  // agent's own, not this plan's.
@@ -1687,6 +1688,7 @@ export class ReplayController {
1687
1688
  // `runToCompletion` returned without a stop: the plan ran to its end, so
1688
1689
  // this is a completing retire (R-MONEY-3). The stopped path handed over
1689
1690
  // above and took no mark.
1691
+ state.ranToEnd = true;
1690
1692
  this.retire(state, undefined, true);
1691
1693
  logLine("replay.done", {
1692
1694
  scenario: state.scenarioId ?? undefined,
@@ -2075,6 +2077,9 @@ export class ReplayController {
2075
2077
  step: `${plan.currentStepIndex}/${plan.stepCount}`,
2076
2078
  why,
2077
2079
  });
2080
+ // Retired, and still working: until the bundle is composed, a call the
2081
+ // model makes beside this one is beside the plan, not after it.
2082
+ state.diverging = true;
2078
2083
  this.retire(state, undefined);
2079
2084
  // The bundle, and the note in front of it, must fit the channel it goes
2080
2085
  // out on whole: the host cuts a long Bash output in the middle.
@@ -2099,6 +2104,11 @@ export class ReplayController {
2099
2104
  });
2100
2105
  return { kind: "abort" };
2101
2106
  }
2107
+ finally {
2108
+ // Nothing below waits: what this divergence ends in — a hand-over, or
2109
+ // a bundle that ran to the end — is settled before any other hook runs.
2110
+ state.diverging = false;
2111
+ }
2102
2112
  const stop = composed.stopped;
2103
2113
  // Same rule as `runArmed`: a recovered chain whose tools errored is
2104
2114
  // `failed`, and so is one that stopped at a step that broke — its logic
@@ -2175,6 +2185,11 @@ export class ReplayController {
2175
2185
  };
2176
2186
  text = `${state.handover.note}\n\n${composed.text}`;
2177
2187
  }
2188
+ else if (!stop) {
2189
+ // The bundle carries the rest of the chain, and its header tells the
2190
+ // model the work is done: from here the model answers from it.
2191
+ state.ranToEnd = true;
2192
+ }
2178
2193
  // Bash-clean delivery: the model reaches for `Bash` when it cannot call a
2179
2194
  // scripted tool, and a genuine command output is trusted where a `deny`
2180
2195
  // reason is read as adversarial interception.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",