@runuai/host 0.9.87 → 0.9.88

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.
@@ -1549,6 +1549,7 @@ export class Orchestrator {
1549
1549
  // can consume/reorder prompt state while the earlier error still owns it.
1550
1550
  let eventTail: Promise<void> = Promise.resolve();
1551
1551
  session.onEvent((event) => {
1552
+ this.markChannelActivity(channel.taskId);
1552
1553
  // Recycle/teardown deletes the old session before calling close().
1553
1554
  // Adapters deliberately emit a final exit event; without this identity
1554
1555
  // guard it can delete a replacement or consume its respawn budget.
@@ -3001,6 +3002,20 @@ export class Orchestrator {
3001
3002
  * reconciles it to `stopped` (worktree present) and it can be resumed. Since
3002
3003
  * `stopped` is not an ACTIVE status, host recovery won't restart it.
3003
3004
  */
3005
+ /** Last observed sign of life per channel: any session event (agent
3006
+ * output, turn boundaries) or an inbound delivery. The idle-stop veto
3007
+ * (ADR-125) reads this — the cloud decides idleness from what it can see,
3008
+ * but only the host can see a turn that is mid-flight RIGHT NOW. */
3009
+ readonly #channelActivity = new Map<string, number>();
3010
+
3011
+ markChannelActivity(taskId: string): void {
3012
+ this.#channelActivity.set(taskId, Date.now());
3013
+ }
3014
+
3015
+ lastChannelActivity(taskId: string): number | null {
3016
+ return this.#channelActivity.get(taskId) ?? null;
3017
+ }
3018
+
3004
3019
  async stopTask(taskId: string): Promise<{ ok: boolean; error?: string }> {
3005
3020
  return this.runTaskLifecycle(taskId, () =>
3006
3021
  this.stopTaskWithinLifecycle(taskId),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.87",
3
+ "version": "0.9.88",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/index.ts CHANGED
@@ -1029,6 +1029,28 @@ export const hostCommands: HostCommands = {
1029
1029
  });
1030
1030
  },
1031
1031
 
1032
+ async taskStop(ctx, taskId, input) {
1033
+ logCommand(ctx, "taskStop", taskId, input.reason);
1034
+ // ADR-125: idle stops are the cloud's judgment from what it can see;
1035
+ // only the host can see a turn that is mid-flight RIGHT NOW. Manual
1036
+ // pause carries no veto — a human's explicit gesture wins.
1037
+ if (input.reason === "idle") {
1038
+ const activeAt = getOrchestrator().lastChannelActivity(taskId);
1039
+ if (activeAt !== null && Date.now() - activeAt < IDLE_STOP_VETO_MS) {
1040
+ return { ok: true, value: { vetoed: "active" as const } };
1041
+ }
1042
+ }
1043
+ const result = await getOrchestrator().stopTask(taskId);
1044
+ if (!result.ok) {
1045
+ return {
1046
+ ok: false,
1047
+ code: HostErrorCode.Internal,
1048
+ message: result.error ?? "stop failed",
1049
+ retryable: true,
1050
+ };
1051
+ }
1052
+ return { ok: true, value: {} };
1053
+ },
1032
1054
  async taskStatus(ctx, taskId) {
1033
1055
  logCommand(ctx, "taskStatus", taskId);
1034
1056
  const runtimeFailure = runtimeUnavailable();
@@ -1112,6 +1134,9 @@ export const hostCommands: HostCommands = {
1112
1134
  const runtimeFailure = runtimeUnavailable();
1113
1135
  if (runtimeFailure) return runtimeFailure;
1114
1136
  try {
1137
+ // An inbound turn is activity: the idle veto must cover the gap between
1138
+ // this delivery landing and the agent's first output event.
1139
+ getOrchestrator().markChannelActivity(taskId);
1115
1140
  const result = await getOrchestrator().deliver(taskId, agentId, text);
1116
1141
  if (!result.ok) {
1117
1142
  return {
@@ -1532,6 +1557,10 @@ function teardownRuntimeUnavailable(): HostCommandResult<never> | null {
1532
1557
  };
1533
1558
  }
1534
1559
 
1560
+ /** ADR-125: an idle stop is vetoed when the channel showed any activity
1561
+ * this recently — a delivery landing or any session event. */
1562
+ const IDLE_STOP_VETO_MS = 120_000;
1563
+
1535
1564
  const RUNTIME_TASK_ADMISSION_WAIT_MS = 3_000;
1536
1565
 
1537
1566
  /**
package/src/main.ts CHANGED
@@ -173,6 +173,7 @@ import {
173
173
  HOST_MAINTENANCE_READINESS_PROTOCOL_FEATURE,
174
174
  HOST_CONFIG_PROTOCOL_FEATURE,
175
175
  HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE,
176
+ HOST_TASK_STOP_PROTOCOL_FEATURE,
176
177
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
177
178
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
178
179
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
@@ -584,6 +585,7 @@ function buildCapabilities(): HostCapabilities {
584
585
  HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
585
586
  HOST_CONFIG_PROTOCOL_FEATURE,
586
587
  HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE,
588
+ HOST_TASK_STOP_PROTOCOL_FEATURE,
587
589
  HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
588
590
  HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
589
591
  // The echo adapter cannot execute the in-task CLI. Advertising typed
@@ -2279,6 +2281,12 @@ async function dispatchCommand(
2279
2281
  const downInput = expectTaskDownInput(args, 0);
2280
2282
  return hostCommands.taskDown(ctx, downInput);
2281
2283
  }
2284
+ case "taskStop":
2285
+ return hostCommands.taskStop(
2286
+ ctx,
2287
+ typeof args[0] === "string" ? args[0] : "",
2288
+ (args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
2289
+ );
2282
2290
  case "taskStatus":
2283
2291
  return hostCommands.taskStatus(ctx, expectString(args, 0));
2284
2292
  case "channelEnsure":
package/src/protocol.ts CHANGED
@@ -89,6 +89,10 @@ export const HOST_CONFIG_PROTOCOL_FEATURE = "host-config-v1";
89
89
  /** Remote update/restart over the bridge (owner-gated cloud routes). */
90
90
  export const HOST_REMOTE_LIFECYCLE_PROTOCOL_FEATURE =
91
91
  "host-remote-lifecycle-v1";
92
+ /** ADR-125: cloud-initiated resumable task stop (idle auto-stop + manual
93
+ * pause). Idle stops carry reason "idle" and the host may VETO them when a
94
+ * turn is active right now — the veto is an ok result, not an error. */
95
+ export const HOST_TASK_STOP_PROTOCOL_FEATURE = "host-task-stop-v1";
92
96
  /** ADR-116: labeled engine accounts on the cloud host page — account list in
93
97
  * capabilities, account-targeted login (`label` on engine.login.start), and
94
98
  * the engineAccountRemove command. */
@@ -1111,6 +1115,19 @@ export interface TaskLaunchInput {
1111
1115
  ownerName?: string;
1112
1116
  }
1113
1117
 
1118
+ /** ADR-125 resumable stop. "idle" = the cloud's sweep decided nobody is
1119
+ * using the task; the host may veto if a turn is live. "manual" = a human
1120
+ * pressed pause; no veto. */
1121
+ export interface TaskStopInput {
1122
+ reason: "idle" | "manual";
1123
+ }
1124
+
1125
+ export interface TaskStopResult {
1126
+ /** Present when the host declined an idle stop because the channel showed
1127
+ * activity within its veto window. The cloud retries on a later sweep. */
1128
+ vetoed?: "active";
1129
+ }
1130
+
1114
1131
  export interface TaskDownInput {
1115
1132
  taskId: string;
1116
1133
  task: TaskCommandTask;
@@ -1296,6 +1313,11 @@ export interface HostCommands {
1296
1313
  ctx: CommandContext,
1297
1314
  taskId: string,
1298
1315
  ): Promise<HostCommandResult<TaskStatusResult>>;
1316
+ taskStop(
1317
+ ctx: CommandContext,
1318
+ taskId: string,
1319
+ input: TaskStopInput,
1320
+ ): Promise<HostCommandResult<TaskStopResult>>;
1299
1321
  channelEnsure(
1300
1322
  ctx: CommandContext,
1301
1323
  input: ChannelEnsureInput,