@llblab/pi-actors 0.20.1 → 0.21.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.
@@ -3,8 +3,9 @@
3
3
  * Zones: async runtime, lifecycle, state files
4
4
  * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
5
  */
6
- import { spawn } from "node:child_process";
6
+ import { spawn, spawnSync } from "node:child_process";
7
7
  import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
8
+ import { createConnection } from "node:net";
8
9
  import { platform } from "node:os";
9
10
  import { basename, dirname, extname, join, resolve } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
@@ -89,7 +90,8 @@ function assertNoActiveRunState(stateDir) {
89
90
  throw new Error(`Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`);
90
91
  }
91
92
  function resolveRecipeFile(file) {
92
- return RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT);
93
+ return (RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
94
+ RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT));
93
95
  }
94
96
  function isMutableUsageRecipeFile(file) {
95
97
  const userRoot = resolve(DEFAULT_RECIPE_ROOT);
@@ -300,6 +302,7 @@ export function startRun(params, cwd) {
300
302
  template: resolved.template,
301
303
  values,
302
304
  ...(artifacts ? { artifacts } : {}),
305
+ ...(startParams.control ? { control: startParams.control } : {}),
303
306
  ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
304
307
  ...(recipeContextRecords && recipeContextRecords.length > 0
305
308
  ? { recipe_context_records: recipeContextRecords }
@@ -507,10 +510,84 @@ export function appendRunOutboxEvent(runOrDir, event) {
507
510
  state_dir: stateDir,
508
511
  };
509
512
  }
510
- export function sendRunMessage(runOrDir, message) {
511
- if (process.platform === "win32") {
512
- throw new Error("run actor messages require Unix FIFO support; use WSL/Linux/macOS or a recipe-specific Windows transport.");
513
+ function getRunControlEndpoint(status, stateDir) {
514
+ const control = status.control;
515
+ if (control && typeof control === "object" && !Array.isArray(control)) {
516
+ const record = control;
517
+ if ((record.type === "fifo" || record.type === "named-pipe") &&
518
+ typeof record.path === "string" &&
519
+ record.path.trim()) {
520
+ return { path: record.path, type: record.type };
521
+ }
522
+ }
523
+ return { path: join(stateDir, "control.fifo"), type: "fifo" };
524
+ }
525
+ function writeRunMessageReceipt(stateDir, message, bytes) {
526
+ const trimmedMessage = message.trim().toLowerCase();
527
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
528
+ const ts = new Date().toISOString();
529
+ writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`, { flag: "a" });
530
+ try {
531
+ const envelope = JSON.parse(message);
532
+ writeFileSync(join(stateDir, "inbox.jsonl"), `${JSON.stringify({ ...envelope, received_at: ts })}\n`, { flag: "a" });
533
+ }
534
+ catch {
535
+ // Plain control lines are already represented in events.jsonl.
536
+ }
537
+ if (terminalMessage) {
538
+ markTerminalHandled(stateDir, {
539
+ event: "run.message",
540
+ message: trimmedMessage,
541
+ });
542
+ }
543
+ }
544
+ function sendRunMessageToFifo(endpoint, payload) {
545
+ if (!existsSync(endpoint.path))
546
+ throw new Error(`Run control FIFO not found: ${endpoint.path}`);
547
+ const stat = statSync(endpoint.path);
548
+ if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
549
+ throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
513
550
  }
551
+ let fd;
552
+ try {
553
+ fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
554
+ return writeSync(fd, payload);
555
+ }
556
+ finally {
557
+ if (fd !== undefined)
558
+ closeSync(fd);
559
+ }
560
+ }
561
+ function sendRunMessageToNamedPipe(endpoint, payload, send) {
562
+ if (send)
563
+ return send(endpoint.path, payload);
564
+ return new Promise((resolve, reject) => {
565
+ const socket = createConnection(endpoint.path);
566
+ let settled = false;
567
+ const timeout = setTimeout(() => {
568
+ if (settled)
569
+ return;
570
+ settled = true;
571
+ socket.destroy();
572
+ reject(new Error("named pipe connection timed out"));
573
+ }, 5000);
574
+ const finish = (error) => {
575
+ if (settled)
576
+ return;
577
+ settled = true;
578
+ clearTimeout(timeout);
579
+ if (error)
580
+ reject(error);
581
+ else
582
+ resolve(Buffer.byteLength(payload));
583
+ };
584
+ socket.on("error", finish);
585
+ socket.on("connect", () => {
586
+ socket.end(payload, () => finish());
587
+ });
588
+ });
589
+ }
590
+ export async function sendRunMessage(runOrDir, message, options = {}) {
514
591
  const status = getRunStatus(runOrDir);
515
592
  const stateDir = String(status.state_dir);
516
593
  const run = String(status.run ?? runOrDir);
@@ -521,52 +598,66 @@ export function sendRunMessage(runOrDir, message) {
521
598
  throw new Error(`Run pid is not alive: ${run}`);
522
599
  if (!pidMatchesRun(pid, String(status.cwd), stateDir))
523
600
  throw new Error(`Run pid owner mismatch: ${run}`);
524
- const controlPath = join(stateDir, "control.fifo");
525
- if (!existsSync(controlPath))
526
- throw new Error(`Run control FIFO not found: ${controlPath}`);
527
- const stat = statSync(controlPath);
528
- if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
529
- throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
530
- }
601
+ const endpoint = getRunControlEndpoint(status, stateDir);
531
602
  const payload = message.endsWith("\n") ? message : `${message}\n`;
532
- let fd;
603
+ const runtimePlatform = options.platform ?? process.platform;
533
604
  try {
534
- fd = openSync(controlPath, constants.O_WRONLY | constants.O_NONBLOCK);
535
- const bytes = writeSync(fd, payload);
536
- const trimmedMessage = message.trim().toLowerCase();
537
- const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
538
- const ts = new Date().toISOString();
539
- writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`, { flag: "a" });
540
- try {
541
- const envelope = JSON.parse(message);
542
- writeFileSync(join(stateDir, "inbox.jsonl"), `${JSON.stringify({ ...envelope, received_at: ts })}\n`, { flag: "a" });
543
- }
544
- catch {
545
- // Plain control lines are already represented in events.jsonl.
546
- }
547
- if (terminalMessage) {
548
- markTerminalHandled(stateDir, {
549
- event: "run.message",
550
- message: trimmedMessage,
551
- });
605
+ if (endpoint.type === "fifo") {
606
+ if (runtimePlatform === "win32") {
607
+ throw new Error("run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.");
608
+ }
609
+ const bytes = sendRunMessageToFifo(endpoint, payload);
610
+ writeRunMessageReceipt(stateDir, message, bytes);
611
+ return {
612
+ bytes,
613
+ control: "control.fifo",
614
+ control_path: endpoint.path,
615
+ control_type: endpoint.type,
616
+ run,
617
+ sent: true,
618
+ state_dir: stateDir,
619
+ };
552
620
  }
621
+ const bytes = await sendRunMessageToNamedPipe(endpoint, payload, options.namedPipeSend);
622
+ writeRunMessageReceipt(stateDir, message, bytes);
553
623
  return {
554
624
  bytes,
555
- control: "control.fifo",
625
+ control: endpoint.path,
626
+ control_path: endpoint.path,
627
+ control_type: endpoint.type,
556
628
  run,
557
629
  sent: true,
558
630
  state_dir: stateDir,
559
631
  };
560
632
  }
561
633
  catch (error) {
562
- throw new Error(`Run control FIFO is not ready: ${controlPath}: ${error instanceof Error ? error.message : String(error)}`);
634
+ throw new Error(`Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`);
563
635
  }
564
- finally {
565
- if (fd !== undefined)
566
- closeSync(fd);
636
+ }
637
+ export function getRunProcessSignalPlan(pid, signal, runtimePlatform = process.platform) {
638
+ if (runtimePlatform === "win32") {
639
+ return {
640
+ args: [
641
+ "/PID",
642
+ String(pid),
643
+ "/T",
644
+ ...(signal === "SIGKILL" ? ["/F"] : []),
645
+ ],
646
+ command: "taskkill",
647
+ signalTarget: "processTree",
648
+ };
567
649
  }
650
+ return { signalTarget: "processGroup" };
568
651
  }
569
652
  function signalOwnedRunProcess(pid, signal) {
653
+ const plan = getRunProcessSignalPlan(pid, signal);
654
+ if (plan.command && plan.args) {
655
+ const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
656
+ if (result.status !== 0) {
657
+ throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`);
658
+ }
659
+ return plan;
660
+ }
570
661
  try {
571
662
  process.kill(-pid, signal);
572
663
  return { signalTarget: "processGroup" };
@@ -74,8 +74,15 @@ function getExecutableName(command) {
74
74
  return "";
75
75
  return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
76
76
  }
77
+ function matchesFlag(arg, flag) {
78
+ if (arg === flag)
79
+ return true;
80
+ if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
81
+ return arg.slice(1).includes(flag.slice(1));
82
+ return false;
83
+ }
77
84
  function hasAnyFlag(args, flags) {
78
- return args.some((arg) => flags.includes(arg));
85
+ return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
79
86
  }
80
87
  function hasRiskyPathArg(args) {
81
88
  return args.some((arg) => arg === "/" ||
@@ -37,9 +37,23 @@ export interface RunSummary {
37
37
  }
38
38
  export interface RunRetirementCandidate {
39
39
  activeSubagents: number;
40
+ childRuns: number;
40
41
  descendantSubagents: number;
41
42
  run: string;
42
43
  stateDir: string;
44
+ terminalChildRuns: number;
45
+ }
46
+ export interface RunRetirementExecution {
47
+ action: "stop" | "cancel" | "skip" | "failed";
48
+ error?: string;
49
+ run: string;
50
+ stateDir: string;
51
+ }
52
+ export interface RunRetirementExecutorOptions {
53
+ attempted?: Set<string>;
54
+ cancelRun: (candidate: RunRetirementCandidate) => Record<string, unknown>;
55
+ notify?: (message: string, level: "info" | "warning" | "error") => void;
56
+ sendStop: (candidate: RunRetirementCandidate) => Promise<unknown>;
43
57
  }
44
58
  export interface RunTransition {
45
59
  from: RunObservedStatus;
@@ -72,6 +86,7 @@ export declare function countRunningSubagents(stateRoot?: string, ownerId?: stri
72
86
  export declare function renderSubagentStatus(count: number, frame?: number): string | undefined;
73
87
  export declare function renderRunStatus(summary: RunSummary, frame?: number): string | undefined;
74
88
  export declare function findRunRetirementCandidates(summary: RunSummary): RunRetirementCandidate[];
89
+ export declare function executeRunRetirements(summary: RunSummary, options: RunRetirementExecutorOptions): Promise<RunRetirementExecution[]>;
75
90
  export declare function detectRunTransitions(previous: Map<string, RunObservedStatus>, summary: RunSummary): RunTransition[];
76
91
  export declare function pruneRunObservationState(previousStatuses: Map<string, RunObservedStatus>, previousLineCounts: Map<string, number>, summary: RunSummary, terminalRuns?: Iterable<string>): void;
77
92
  export declare function detectRunOutboxEvents(previousLineCounts: Map<string, number>, summary: RunSummary): RunOutboxEvent[];
@@ -4,7 +4,7 @@
4
4
  * Owns ambient summaries, terminal events, and run outbox delivery for detached command-template runs
5
5
  */
6
6
  import { existsSync, readdirSync, readFileSync } from "node:fs";
7
- import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
8
  import * as AsyncRuns from "./async-runs.js";
9
9
  import * as Paths from "./paths.js";
10
10
  const TERMINAL = new Set([
@@ -15,6 +15,7 @@ const TERMINAL = new Set([
15
15
  "killed",
16
16
  ]);
17
17
  const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
18
+ const RUN_STATE_DISCOVERY_MAX_DEPTH = 8;
18
19
  const procDescendantScanCache = new Map();
19
20
  function toNumber(value) {
20
21
  const number = Number(value);
@@ -34,6 +35,29 @@ function getUpdatedAt(status) {
34
35
  ? status.createdAt
35
36
  : undefined;
36
37
  }
38
+ function listRunStateDirs(stateRoot, depth = 0, seen = new Set()) {
39
+ if (!existsSync(stateRoot) || seen.has(stateRoot))
40
+ return [];
41
+ seen.add(stateRoot);
42
+ let entries;
43
+ try {
44
+ entries = readdirSync(stateRoot, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ const result = [];
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory())
52
+ continue;
53
+ const child = join(stateRoot, entry.name);
54
+ if (existsSync(join(child, "run.json")))
55
+ result.push(child);
56
+ if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
57
+ result.push(...listRunStateDirs(child, depth + 1, seen));
58
+ }
59
+ return result;
60
+ }
37
61
  function observeRun(stateDir) {
38
62
  try {
39
63
  const status = AsyncRuns.getRunStatus(stateDir);
@@ -90,9 +114,8 @@ export function summarizeRuns(stateRoot = Paths.getRunStateRoot(), ownerId) {
90
114
  total: 0,
91
115
  };
92
116
  }
93
- const runs = readdirSync(stateRoot, { withFileTypes: true })
94
- .filter((entry) => entry.isDirectory())
95
- .map((entry) => observeRun(join(stateRoot, entry.name)))
117
+ const runs = listRunStateDirs(stateRoot)
118
+ .map((stateDir) => observeRun(stateDir))
96
119
  .filter((run) => Boolean(run))
97
120
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
98
121
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
@@ -148,7 +171,7 @@ function getRunningRunPidMap(stateRoot, ownerId) {
148
171
  for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
149
172
  if (run.status !== "running")
150
173
  continue;
151
- const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
174
+ const status = AsyncRuns.getRunStatus(run.stateDir ?? join(stateRoot, run.run));
152
175
  const pid = Number(status.pid || 0);
153
176
  if (pid > 0)
154
177
  pids.set(String(pid), run.run);
@@ -167,9 +190,8 @@ function summarizeRunsWithoutSubagents(stateRoot, ownerId) {
167
190
  runs: [],
168
191
  total: 0,
169
192
  };
170
- const runs = readdirSync(stateRoot, { withFileTypes: true })
171
- .filter((entry) => entry.isDirectory())
172
- .map((entry) => observeRun(join(stateRoot, entry.name)))
193
+ const runs = listRunStateDirs(stateRoot)
194
+ .map((stateDir) => observeRun(stateDir))
173
195
  .filter((run) => Boolean(run))
174
196
  .filter((run) => ownerId === undefined || run.ownerId === ownerId)
175
197
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
@@ -260,23 +282,86 @@ export function renderSubagentStatus(count, frame = 0) {
260
282
  export function renderRunStatus(summary, frame = 0) {
261
283
  return renderSubagentStatus(summary.runningSubagents, frame);
262
284
  }
285
+ function isNestedStateDir(parent, child) {
286
+ const path = relative(parent, child);
287
+ return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
288
+ }
263
289
  export function findRunRetirementCandidates(summary) {
264
290
  return summary.runs
265
- .filter((run) => {
291
+ .map((run) => {
266
292
  const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
267
293
  const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
268
- return (run.status === "running" &&
269
- run.retireWhen === "children_terminal" &&
270
- run.stateDir &&
271
- activeSubagents + descendantSubagents <= 0);
294
+ const childRuns = run.stateDir
295
+ ? summary.runs.filter((child) => child.stateDir !== undefined &&
296
+ child.stateDir !== run.stateDir &&
297
+ isNestedStateDir(run.stateDir, child.stateDir))
298
+ : [];
299
+ const runningChildRuns = childRuns.filter((child) => child.status === "running").length;
300
+ return {
301
+ activeSubagents,
302
+ childRuns: childRuns.length,
303
+ descendantSubagents,
304
+ ready: run.status === "running" &&
305
+ run.retireWhen === "children_terminal" &&
306
+ run.stateDir !== undefined &&
307
+ !run.terminalHandled &&
308
+ activeSubagents + descendantSubagents + runningChildRuns <= 0,
309
+ run,
310
+ terminalChildRuns: childRuns.filter((child) => TERMINAL.has(child.status)).length,
311
+ };
272
312
  })
273
- .map((run) => ({
274
- activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
275
- descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
276
- run: run.run,
277
- stateDir: run.stateDir,
313
+ .filter((item) => item.ready)
314
+ .map((item) => ({
315
+ activeSubagents: item.activeSubagents,
316
+ childRuns: item.childRuns,
317
+ descendantSubagents: item.descendantSubagents,
318
+ run: item.run.run,
319
+ stateDir: item.run.stateDir,
320
+ terminalChildRuns: item.terminalChildRuns,
278
321
  }));
279
322
  }
323
+ export async function executeRunRetirements(summary, options) {
324
+ const results = [];
325
+ for (const candidate of findRunRetirementCandidates(summary)) {
326
+ if (options.attempted?.has(candidate.stateDir)) {
327
+ results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
328
+ continue;
329
+ }
330
+ options.attempted?.add(candidate.stateDir);
331
+ try {
332
+ await options.sendStop(candidate);
333
+ options.notify?.(`Retiring actor ${candidate.run} after child runs reached terminal state`, "info");
334
+ results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
335
+ continue;
336
+ }
337
+ catch (error) {
338
+ try {
339
+ const cancelResult = options.cancelRun(candidate);
340
+ const cancelled = Boolean(cancelResult.cancelled);
341
+ options.notify?.(cancelled
342
+ ? `Retiring actor ${candidate.run} by cancellation after graceful stop failed`
343
+ : `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`, cancelled ? "warning" : "error");
344
+ results.push({
345
+ action: cancelled ? "cancel" : "skip",
346
+ ...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
347
+ run: candidate.run,
348
+ stateDir: candidate.stateDir,
349
+ });
350
+ }
351
+ catch (cancelError) {
352
+ const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
353
+ options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
354
+ results.push({
355
+ action: "failed",
356
+ error: message,
357
+ run: candidate.run,
358
+ stateDir: candidate.stateDir,
359
+ });
360
+ }
361
+ }
362
+ }
363
+ return results;
364
+ }
280
365
  export function detectRunTransitions(previous, summary) {
281
366
  const transitions = [];
282
367
  for (const run of summary.runs) {
@@ -36,15 +36,18 @@ function listRecipeFiles(root) {
36
36
  return [];
37
37
  return readdirSync(root, { withFileTypes: true })
38
38
  .filter((entry) => entry.isFile() &&
39
- entry.name.endsWith(".json") &&
39
+ (entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
40
40
  entry.name !== "legacy-tool-registry-migration-report.json")
41
41
  .map((entry) => join(root, entry.name))
42
- .sort();
42
+ .sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
43
43
  }
44
44
  function getRecipeConfigDiagnostics(file, config) {
45
45
  if (!config)
46
46
  return [`Invalid recipe: ${file}`];
47
- return CommandTemplates.getCommandTemplateWarnings(typeof config.template === "string" ? config.template : { template: config.template }).map((warning) => `Recipe ${file}: ${warning}`);
47
+ const commandTemplateConfig = typeof config.template === "object" && config.template !== null
48
+ ? config.template
49
+ : config;
50
+ return CommandTemplates.getCommandTemplateWarnings(commandTemplateConfig).map((warning) => `Recipe ${file}: ${warning}`);
48
51
  }
49
52
  function readDiscoveredRecipe(root, file, priority, defaultTool = false, mutableUsage = false) {
50
53
  const id = RecipeReferences.getRecipeIdFromPath(file);
@@ -135,13 +138,18 @@ export function discoverRecipeSources(sources) {
135
138
  const active = new Map();
136
139
  const diagnostics = getRecipeRootDiagnostics(sources);
137
140
  for (const [id, bucket] of byId) {
138
- bucket.sort((a, b) => a.priority - b.priority || a.path.localeCompare(b.path));
141
+ bucket.sort((a, b) => a.priority - b.priority ||
142
+ a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
143
+ (a.path.endsWith(".json") ? -1 : 1));
139
144
  const winner = bucket[0];
140
145
  winner.active = true;
141
146
  winner.shadows = bucket.slice(1).map((entry) => entry.path);
142
147
  active.set(id, winner);
143
- for (const shadow of bucket.slice(1))
148
+ for (const shadow of bucket.slice(1)) {
144
149
  shadow.shadowed = true;
150
+ if (winner.path.endsWith(".json") && shadow.path.endsWith(".md"))
151
+ shadow.diagnostics.push(`Markdown recipe ${shadow.path} is shadowed by JSON recipe ${winner.path}`);
152
+ }
145
153
  if (winner.invalid)
146
154
  diagnostics.push(`Recipe ${id} is invalid and blocks lower-priority recipes`);
147
155
  if (winner.disabled)