refactor: gpt 스트림 오류 최소화
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Agent Execution Policy
|
||||
|
||||
## Coka MCP long-running work
|
||||
|
||||
Use the managed-task tools for builds, tests, package installs, Docker builds, integration tests, migrations, benchmarks, and any command that may take more than a few seconds or emit substantial output.
|
||||
|
||||
- Prefer `start_managed_task` over `exec_command` for long-running work.
|
||||
- Give every managed task a stable, descriptive `taskKey` such as `root-ci`, `backend-tests`, or `docker-build-api`.
|
||||
- Reuse the same `taskKey` when recovering after a ChatGPT/message-stream interruption. Do not start the same command again merely because the response stream was interrupted.
|
||||
- Before retrying work after an interruption, call `list_managed_tasks` or `read_managed_task` and inspect the existing task state.
|
||||
- Use `read_managed_task` with long waits rather than repeatedly polling at short intervals.
|
||||
- Managed-task responses are intentionally compact. Full stdout/stderr is stored in the task log file; inspect only the reported tail/highlights unless deeper diagnostics are required.
|
||||
- Do not dump full build/test logs into MCP responses. Prefer exit status, important lines, and a bounded tail.
|
||||
- Group related repository inspection and edits into coherent calls instead of issuing many tiny shell commands.
|
||||
- Run targeted tests before the full suite. Run the full CI/build once near the end unless a failure requires another run.
|
||||
- Never re-run an already successful build/test solely to reconstruct conversational context.
|
||||
|
||||
## Low-level process tools
|
||||
|
||||
Use `exec_command`, `read_process`, `write_stdin`, and `terminate_process` when interactive stdin, exact paged stdout/stderr, or low-level process control is specifically required. They remain the escape hatch, not the default for long-running non-interactive work.
|
||||
|
||||
## Recovery order
|
||||
|
||||
When a response is interrupted:
|
||||
|
||||
1. Inspect `list_managed_tasks`.
|
||||
2. Reuse the matching task by `taskKey`.
|
||||
3. If it is still running, wait with `read_managed_task`.
|
||||
4. If it finished, consume its existing result and log summary.
|
||||
5. Re-run only when the prior task failed for a reason that requires a retry, or when an explicit fresh run is requested.
|
||||
@@ -67,7 +67,7 @@ Typical tasks include:
|
||||
- "Install Node.js packages and build the project."
|
||||
- "Upload a file, verify its hash, and move it into place."
|
||||
|
||||
Internally, these actions are provided through 20 MCP tools for shell execution, long-running processes, and filesystem operations.
|
||||
Internally, these actions are provided through 24 MCP tools for shell execution, recoverable managed tasks, low-level process control, and filesystem operations.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -83,7 +83,8 @@ The MCP transport is stateless, but long-running command sessions are kept in me
|
||||
## Key features
|
||||
|
||||
- Shell commands, complete scripts, builds, tests, package installation, Git, and service management
|
||||
- Output polling, stdin delivery, and termination control for long-running processes
|
||||
- Recoverable long-running tasks with stable task keys, duplicate-run protection, compact responses, and full on-disk logs
|
||||
- Low-level output polling, stdin delivery, and termination control for interactive processes
|
||||
- Read, write, edit, transfer, and delete host files, including absolute paths
|
||||
- Built-in static Bearer authentication and OAuth 2.1/DCR/PKCE for compatible MCP clients
|
||||
- Stateless JSON transport per request, per-process output retention, and response size limits
|
||||
@@ -93,13 +94,32 @@ The MCP transport is stateless, but long-running command sessions are kept in me
|
||||
|
||||
### Execution and processes
|
||||
|
||||
- `exec_command`: Run shell commands, builds, tests, package installation, Git, service management, and log inspection
|
||||
For long-running, non-interactive builds, tests, package installation, Docker builds, migrations, and similar work, prefer the managed-task tools:
|
||||
|
||||
- `start_managed_task`: Start a shell task under a stable `taskKey`, persist its complete output to a log file, and reuse an existing retained task instead of accidentally running it twice
|
||||
- `read_managed_task`: Recover a task by `taskKey`, optionally wait for completion, and return compact status, important diagnostic lines, and a bounded log tail
|
||||
- `list_managed_tasks`: List running and recently retained tasks by stable key after a client/message-stream interruption
|
||||
- `cancel_managed_task`: Terminate a managed task by stable key
|
||||
|
||||
The low-level process tools remain available when interactive stdin or exact cursor-based output is required:
|
||||
|
||||
- `exec_command`: Run a shell command directly and return immediately when it completes or expose its low-level process session
|
||||
- `run_script`: Run complete scripts with Bash, sh, Node.js, Python, or an arbitrary interpreter
|
||||
- `write_stdin`: Write input to a long-running process and retrieve subsequent output
|
||||
- `read_process`: Poll output using a cursor and inspect process termination state
|
||||
- `terminate_process`: Send `SIGINT`, `SIGTERM`, or `SIGKILL` to a managed process group
|
||||
- `list_processes`: List running or recently completed process sessions
|
||||
|
||||
A typical resilient workflow is:
|
||||
|
||||
1. Call `start_managed_task` with a stable key such as `root-ci`.
|
||||
2. If the client response is interrupted, call `list_managed_tasks` instead of starting the command again.
|
||||
3. Continue with `read_managed_task` using the same key and a longer `waitMs`.
|
||||
4. Inspect the returned status/highlights/tail. Read the full log file only when deeper diagnostics are actually needed.
|
||||
5. Use `restartCompleted=true` only when a fresh run is intentionally required.
|
||||
|
||||
Managed task identity is retained in service memory for the same period as process sessions. A service restart therefore loses the `taskKey` mapping even though a previously written temporary log file may still exist.
|
||||
|
||||
### Filesystem
|
||||
|
||||
- `list_directory`, `stat_path`, `read_file`, `write_file`
|
||||
@@ -109,7 +129,7 @@ The MCP transport is stateless, but long-running command sessions are kept in me
|
||||
|
||||
Relative paths are resolved from `MCP_DEFAULT_CWD`, while absolute paths and `~/...` paths are also allowed. Uploads and downloads use base64 chunk transfer with `nextOffset`.
|
||||
|
||||
The server provides 20 tools in total. `remove_path` permanently deletes targets without using a trash folder, and `apply_patch` uses the host's `git apply --unsafe-paths`.
|
||||
The server provides 24 tools in total. `remove_path` permanently deletes targets without using a trash folder, and `apply_patch` uses the host's `git apply --unsafe-paths`.
|
||||
|
||||
### Tool safety and authentication metadata
|
||||
|
||||
@@ -117,11 +137,11 @@ Every tool explicitly publishes all four MCP safety hints. The values describe t
|
||||
|
||||
| Behavior | Tools | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| Read-only, closed world | `list_directory`, `stat_path`, `read_file`, `download_file`, `hash_file`, `read_process`, `list_processes` | `true` | `false` | `true` | `false` |
|
||||
| Read-only, closed world | `list_directory`, `stat_path`, `read_file`, `download_file`, `hash_file`, `read_process`, `list_processes`, `read_managed_task`, `list_managed_tasks` | `true` | `false` | `true` | `false` |
|
||||
| Additive and idempotent | `make_directory` | `false` | `false` | `true` | `false` |
|
||||
| Destructive and idempotent | `upload_file`, `copy_path`, `move_path`, `remove_path`, `chmod_path` | `false` | `true` | `true` | `false` |
|
||||
| Destructive and non-idempotent, closed world | `write_file`, `replace_in_file`, `apply_patch`, `terminate_process` | `false` | `true` | `false` | `false` |
|
||||
| Destructive and non-idempotent, open world | `exec_command`, `run_script`, `write_stdin` | `false` | `true` | `false` | `true` |
|
||||
| Destructive and non-idempotent, closed world | `write_file`, `replace_in_file`, `apply_patch`, `terminate_process`, `cancel_managed_task` | `false` | `true` | `false` | `false` |
|
||||
| Destructive and non-idempotent, open world | `exec_command`, `run_script`, `write_stdin`, `start_managed_task` | `false` | `true` | `false` | `true` |
|
||||
|
||||
These annotations are advisory client metadata, not access control. They do not replace authentication, which is enforced by the built-in HTTP layer or an upstream gateway when configured. When built-in OAuth is enabled, every tool advertises the `oauth2` security scheme with the `mcp:tools` scope through `_meta.securitySchemes`. Static-Bearer-only and built-in-auth-disabled (`MCP_ALLOW_NO_AUTH`) deployments intentionally omit this OpenAI extension: a pre-shared Bearer token is neither `noauth` nor `oauth2`, while disabling built-in authentication may represent a deliberately anonymous endpoint, upstream authentication, or private-network access. The process cannot infer that external policy honestly, so authentication, if any, remains connection- or deployment-level.
|
||||
|
||||
@@ -142,7 +162,7 @@ These annotations are advisory client metadata, not access control. They do not
|
||||
- If an older client sends a stale `Mcp-Session-Id` header, the server ignores it for request processing.
|
||||
- An authenticated `GET /mcp` or `DELETE /mcp` request returning `405 Method Not Allowed` is expected. Missing or invalid authentication may produce `401 Unauthorized` before the request reaches that method check. The server does not maintain a server-push SSE session.
|
||||
- MCP transport sessions and command process `sessionId` values are unrelated. A process `sessionId` returned by `exec_command` can be reused by later HTTP requests to `write_stdin`, `read_process`, and `terminate_process`.
|
||||
- Running and retained process state is stored in service memory and is lost when the service restarts.
|
||||
- Running and retained process state, including managed-task key mappings, is stored in service memory and is lost when the service restarts. Managed-task full logs are written beneath the host temporary directory and are not a durable task registry.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -327,7 +347,8 @@ sudo journalctl -u remote-dev-mcp -o cat | grep '"event":"mcp_request"'
|
||||
- `Error fetching OAuth configuration`: Check `MCP_OAUTH_ENABLED`, the public URL, and the Nginx proxy for `/.well-known/` routes.
|
||||
- `401 Unauthorized` on MCP requests: Check the Bearer token or OAuth access token.
|
||||
- `403 Host header is not allowed`: Add the request domain to `MCP_ALLOWED_HOSTS`.
|
||||
- A command returns a `sessionId` instead of completing immediately: Poll it with `read_process` or send input with `write_stdin`.
|
||||
- A managed task is still running: Reuse its `taskKey` with `read_managed_task`; after a client interruption, call `list_managed_tasks` before starting anything again.
|
||||
- A low-level command returns a `sessionId` instead of completing immediately: Poll it with `read_process` or send input with `write_stdin`.
|
||||
- MCP requests are independent stateless POST requests. An authenticated `GET /mcp` or `DELETE /mcp` returning `405 Method Not Allowed` is expected and means the server does not provide a separate SSE stream. Authentication failures may return `401 Unauthorized` first.
|
||||
- Service restart behavior: Managed process state and unexchanged authorization codes are lost. OAuth client registrations and issued tokens remain in the state file.
|
||||
|
||||
@@ -342,14 +363,14 @@ npm run build
|
||||
The default tests use a real Streamable HTTP MCP client and cover:
|
||||
|
||||
- Bearer authentication, stateless request processing, and request tracing headers
|
||||
- Success paths, failure paths, and input boundary cases for all 20 tools
|
||||
- Success paths, failure paths, and input boundary cases for all 24 tools
|
||||
- Interactive stdin, output pagination, timeouts, termination, and completed-process retention
|
||||
- UTF-8 character boundaries, strict base64 validation, file modes, and copy/move conflicts
|
||||
- Unified diff validation, application, reverse application, and 3-way application
|
||||
|
||||
### Full E2E verification against a running external MCP server
|
||||
|
||||
From a separate source checkout with development dependencies installed, you can verify all 20 tools against a real HTTPS endpoint:
|
||||
From a separate source checkout with development dependencies installed, you can verify all 24 tools against a real HTTPS endpoint:
|
||||
|
||||
```bash
|
||||
MCP_E2E_URL='https://mcp.example.com/mcp' \
|
||||
@@ -396,12 +417,14 @@ This verification executes real commands on the target server and creates, modif
|
||||
|---|---|
|
||||
| `src/http-server.ts` | Stateless Streamable HTTP, OAuth routing, and health endpoint |
|
||||
| `src/mcp-server.ts` | MCP server metadata and tool registration |
|
||||
| `src/exec-tools.ts` | Command, script, and long-running process tools |
|
||||
| `src/exec-tools.ts` | Low-level command, script, and process-control tools |
|
||||
| `src/managed-task-manager.ts` | Stable task-key registry, full task logs, deduplication, recovery, and compact summaries |
|
||||
| `src/managed-task-tools.ts` | High-level managed-task MCP schemas and tool registration |
|
||||
| `src/file-service.ts` | File reading, writing, transfer, and path operations |
|
||||
| `src/file-tools.ts` | Filesystem tools and input schemas |
|
||||
| `src/oauth.ts` | DCR, PKCE, token issuance/refresh/revocation, and approval UI |
|
||||
| `deploy/` | systemd, environment-file, and Nginx examples |
|
||||
| `test/all-tools.integration.test.ts` | E2E tests for all 20 tools and external endpoints |
|
||||
| `test/all-tools.integration.test.ts` | E2E tests for all 24 tools and external endpoints |
|
||||
| `test/` | Configuration, file, process, MCP, and OAuth unit/integration tests |
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Managed Task Tools Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add recoverable, compact-output managed task tools for long-running Coka MCP commands.
|
||||
|
||||
**Architecture:** Add a `ManagedTaskManager` facade on top of the existing `ProcessManager`. Extend process startup with an internal output observer so managed tasks can persist complete output without changing low-level tool responses, then register four additive MCP tools.
|
||||
|
||||
**Tech Stack:** Node.js 22+, TypeScript, MCP SDK, Zod, Vitest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-17-managed-task-tools-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Preserve all existing low-level process tools and semantics.
|
||||
- Do not execute the same retained managed task twice when the same task key, command, and cwd are supplied.
|
||||
- Store full managed-task output on disk while keeping MCP responses bounded.
|
||||
- Recovery must work by stable task key after a response-stream interruption.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Managed task core
|
||||
|
||||
**Files:**
|
||||
- Create: `src/managed-task-manager.ts`
|
||||
- Modify: `src/process-manager.ts`
|
||||
- Test: `test/managed-task-manager.test.ts`
|
||||
|
||||
- [x] Write failing tests for reuse, compact summaries/full logs, restart, cancellation, and recovery listing.
|
||||
- [x] Run the focused test and verify RED.
|
||||
- [x] Add the smallest ProcessManager output-observer seam and ManagedTaskManager implementation.
|
||||
- [x] Run the focused test and verify GREEN.
|
||||
|
||||
### Task 2: MCP tool surface
|
||||
|
||||
**Files:**
|
||||
- Create: `src/managed-task-tools.ts`
|
||||
- Modify: `src/mcp-server.ts`
|
||||
- Modify: `test/tool-metadata.test.ts`
|
||||
- Modify: `test/all-tools.integration.test.ts`
|
||||
|
||||
- [x] Update inventory/metadata tests first and verify RED.
|
||||
- [x] Register `start_managed_task`, `read_managed_task`, `list_managed_tasks`, and `cancel_managed_task`.
|
||||
- [x] Exercise start/reuse/read/list/cancel through MCP integration tests.
|
||||
- [x] Verify focused tests GREEN.
|
||||
|
||||
### Task 3: Documentation and regression verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Create: `AGENTS.md`
|
||||
|
||||
- [x] Document the high-level managed-task workflow and recovery behavior.
|
||||
- [x] Run `npm test`.
|
||||
- [x] Run `npm run typecheck`.
|
||||
- [x] Run `npm run build`.
|
||||
- [x] Run `git diff --check` and inspect the final diff.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Managed Task Tools Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make long-running Coka MCP work resilient to ChatGPT response-stream interruption while reducing tool-call count and response payload size.
|
||||
|
||||
## Design
|
||||
|
||||
Keep the existing `ProcessManager` and all low-level process tools intact. Add a `ManagedTaskManager` facade that owns stable task keys, writes the complete process output to a log file, returns bounded summaries, and maps task keys back to process session IDs.
|
||||
|
||||
A managed task is identified by a caller-supplied `taskKey`. Calling start again with the same key and same command/cwd reuses the retained task instead of executing it twice. A completed task may be explicitly restarted. Reusing a key for a different command/cwd is rejected unless the prior task has completed and an explicit restart is requested.
|
||||
|
||||
## MCP tools
|
||||
|
||||
- `start_managed_task`: start or safely reuse a non-interactive long-running shell command. Wait only for a small initial interval, then return compact state.
|
||||
- `read_managed_task`: optionally long-poll a task and return compact state, important lines, and a bounded log tail.
|
||||
- `list_managed_tasks`: list recoverable running/recent tasks by stable task key.
|
||||
- `cancel_managed_task`: terminate a running managed task using the existing process-tree termination semantics.
|
||||
|
||||
## Log behavior
|
||||
|
||||
Full stdout and stderr are appended to a per-task log beneath the configured managed-task log directory. The MCP result never returns the full log by default. It returns a bounded tail plus selected important lines captured across the full output stream (errors, failures, warnings, successful/failed build markers, and common test summaries), so early failures are not lost when later output pushes them outside the tail.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing `exec_command`, `run_script`, `read_process`, `write_stdin`, `terminate_process`, and `list_processes` behavior remains unchanged. Managed-task functionality is additive.
|
||||
@@ -0,0 +1,364 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
appendFileSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
readSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ProcessManager,
|
||||
type ProcessOutputStream,
|
||||
type StartProcessRequest,
|
||||
} from "./process-manager.js";
|
||||
|
||||
const MIN_TAIL_BYTES = 1024;
|
||||
const STATUS_OUTPUT_BYTES = 16 * 1024;
|
||||
const IMPORTANT_LINE_LIMIT = 40;
|
||||
const MAX_PENDING_LINE_CHARS = 16 * 1024;
|
||||
const IMPORTANT_LINE =
|
||||
/\b(error|errors|failed|failure|failures|exception|warning|warn|build successful|build failed|tests?\b.*\b(?:passed|failed)|test files\b.*\b(?:passed|failed))\b/i;
|
||||
|
||||
export interface ManagedTaskManagerOptions {
|
||||
logDirectory: string;
|
||||
defaultTailBytes: number;
|
||||
maxTailBytes: number;
|
||||
}
|
||||
|
||||
export interface StartManagedTaskRequest extends StartProcessRequest {
|
||||
taskKey: string;
|
||||
initialWaitMs?: number | undefined;
|
||||
tailBytes?: number | undefined;
|
||||
restartCompleted?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface ReadManagedTaskRequest {
|
||||
waitMs?: number | undefined;
|
||||
tailBytes?: number | undefined;
|
||||
}
|
||||
|
||||
export type ManagedTaskResult = {
|
||||
taskKey: string;
|
||||
sessionId: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
running: boolean;
|
||||
completed: boolean;
|
||||
status: "running" | "succeeded" | "failed" | "timed_out" | "cancelled";
|
||||
pid: number | undefined;
|
||||
startedAt: string;
|
||||
endedAt: string | undefined;
|
||||
wallTimeMs: number;
|
||||
exitCode: number | null | undefined;
|
||||
signal: NodeJS.Signals | null | undefined;
|
||||
timedOut: boolean;
|
||||
error: string | undefined;
|
||||
succeeded: boolean | undefined;
|
||||
logPath: string;
|
||||
logBytes: number;
|
||||
tail: string;
|
||||
importantLines: string[];
|
||||
reused: boolean;
|
||||
};
|
||||
|
||||
interface ManagedTaskEntry {
|
||||
taskKey: string;
|
||||
sessionId: string;
|
||||
executable: string;
|
||||
args: string[];
|
||||
command: string;
|
||||
cwd: string;
|
||||
logPath: string;
|
||||
importantLines: string[];
|
||||
pendingLines: Record<ProcessOutputStream, string>;
|
||||
}
|
||||
|
||||
function sameStringArray(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function safeTaskName(taskKey: string): string {
|
||||
const readable = taskKey
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "task";
|
||||
const digest = createHash("sha256").update(taskKey).digest("hex").slice(0, 10);
|
||||
return `${readable}-${digest}`;
|
||||
}
|
||||
|
||||
function skipUtf8ContinuationBytes(data: Buffer): Buffer {
|
||||
let offset = 0;
|
||||
while (offset < data.length && data[offset]! >= 0x80 && data[offset]! <= 0xbf) {
|
||||
offset += 1;
|
||||
}
|
||||
return data.subarray(offset);
|
||||
}
|
||||
|
||||
export class ManagedTaskManager {
|
||||
readonly #processManager: ProcessManager;
|
||||
readonly #options: ManagedTaskManagerOptions;
|
||||
readonly #tasks = new Map<string, ManagedTaskEntry>();
|
||||
|
||||
constructor(processManager: ProcessManager, options: ManagedTaskManagerOptions) {
|
||||
this.#processManager = processManager;
|
||||
this.#options = {
|
||||
...options,
|
||||
defaultTailBytes: Math.max(MIN_TAIL_BYTES, options.defaultTailBytes),
|
||||
maxTailBytes: Math.max(MIN_TAIL_BYTES, options.maxTailBytes),
|
||||
};
|
||||
mkdirSync(this.#options.logDirectory, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
async start(request: StartManagedTaskRequest): Promise<ManagedTaskResult> {
|
||||
const taskKey = request.taskKey.trim();
|
||||
if (!taskKey) {
|
||||
throw new Error("taskKey must not be empty");
|
||||
}
|
||||
|
||||
const existing = this.#tasks.get(taskKey);
|
||||
if (existing) {
|
||||
if (!this.#isRetained(existing.sessionId)) {
|
||||
this.#tasks.delete(taskKey);
|
||||
} else {
|
||||
const existingState = await this.#processManager.read(existing.sessionId, {
|
||||
maxOutputBytes: STATUS_OUTPUT_BYTES,
|
||||
});
|
||||
if (request.restartCompleted === true) {
|
||||
if (existingState.running) {
|
||||
throw new Error(`Managed task '${taskKey}' is still running and cannot be restarted`);
|
||||
}
|
||||
this.#tasks.delete(taskKey);
|
||||
} else {
|
||||
if (!this.#matches(existing, request)) {
|
||||
throw new Error(
|
||||
`Managed task key '${taskKey}' already refers to a different command or working directory`,
|
||||
);
|
||||
}
|
||||
return this.#result(existing, request.tailBytes, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logPath = path.join(
|
||||
this.#options.logDirectory,
|
||||
`${safeTaskName(taskKey)}-${Date.now()}-${randomUUID().slice(0, 8)}.log`,
|
||||
);
|
||||
writeFileSync(logPath, Buffer.alloc(0), { mode: 0o600 });
|
||||
|
||||
const importantLines: string[] = [];
|
||||
const pendingLines: Record<ProcessOutputStream, string> = { stdout: "", stderr: "" };
|
||||
const sessionId = this.#processManager.start({
|
||||
executable: request.executable,
|
||||
args: request.args,
|
||||
commandForDisplay: request.commandForDisplay,
|
||||
cwd: request.cwd,
|
||||
env: request.env,
|
||||
timeoutMs: request.timeoutMs,
|
||||
stdin: request.stdin,
|
||||
cleanup: request.cleanup,
|
||||
onOutput: (stream, data) => {
|
||||
appendFileSync(logPath, data);
|
||||
this.#captureImportantLines(importantLines, pendingLines, stream, data);
|
||||
},
|
||||
});
|
||||
const entry: ManagedTaskEntry = {
|
||||
taskKey,
|
||||
sessionId,
|
||||
executable: request.executable,
|
||||
args: [...request.args],
|
||||
command: request.commandForDisplay,
|
||||
cwd: request.cwd,
|
||||
logPath,
|
||||
importantLines,
|
||||
pendingLines,
|
||||
};
|
||||
this.#tasks.set(taskKey, entry);
|
||||
|
||||
await this.#processManager.waitForExit(sessionId, Math.max(0, request.initialWaitMs ?? 1000));
|
||||
return this.#result(entry, request.tailBytes, false);
|
||||
}
|
||||
|
||||
async read(
|
||||
taskKey: string,
|
||||
request: ReadManagedTaskRequest = {},
|
||||
): Promise<ManagedTaskResult> {
|
||||
const entry = this.#require(taskKey);
|
||||
await this.#processManager.waitForExit(entry.sessionId, Math.max(0, request.waitMs ?? 0));
|
||||
return this.#result(entry, request.tailBytes, true);
|
||||
}
|
||||
|
||||
async list(): Promise<Array<Omit<ManagedTaskResult, "tail" | "importantLines" | "reused">>> {
|
||||
const retained = new Map(this.#processManager.list().map((process) => [process.sessionId, process]));
|
||||
const results: Array<Omit<ManagedTaskResult, "tail" | "importantLines" | "reused">> = [];
|
||||
|
||||
for (const [taskKey, entry] of this.#tasks) {
|
||||
if (!retained.has(entry.sessionId)) {
|
||||
this.#tasks.delete(taskKey);
|
||||
continue;
|
||||
}
|
||||
const result = await this.#result(entry, MIN_TAIL_BYTES, true);
|
||||
const { tail: _tail, importantLines: _importantLines, reused: _reused, ...compact } = result;
|
||||
results.push(compact);
|
||||
}
|
||||
|
||||
return results.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
||||
}
|
||||
|
||||
async cancel(
|
||||
taskKey: string,
|
||||
signal: NodeJS.Signals = "SIGTERM",
|
||||
graceMs = 3000,
|
||||
tailBytes?: number,
|
||||
): Promise<ManagedTaskResult> {
|
||||
const entry = this.#require(taskKey);
|
||||
await this.#processManager.terminate(entry.sessionId, signal, graceMs);
|
||||
return this.#result(entry, tailBytes, true);
|
||||
}
|
||||
|
||||
#require(taskKey: string): ManagedTaskEntry {
|
||||
const normalized = taskKey.trim();
|
||||
const entry = this.#tasks.get(normalized);
|
||||
if (!entry) {
|
||||
throw new Error(`Unknown managed task: ${normalized}`);
|
||||
}
|
||||
if (!this.#isRetained(entry.sessionId)) {
|
||||
this.#tasks.delete(normalized);
|
||||
throw new Error(`Managed task '${normalized}' is no longer retained`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
#isRetained(sessionId: string): boolean {
|
||||
return this.#processManager.list().some((process) => process.sessionId === sessionId);
|
||||
}
|
||||
|
||||
#matches(entry: ManagedTaskEntry, request: StartManagedTaskRequest): boolean {
|
||||
return (
|
||||
entry.executable === request.executable &&
|
||||
sameStringArray(entry.args, request.args) &&
|
||||
entry.command === request.commandForDisplay &&
|
||||
entry.cwd === request.cwd
|
||||
);
|
||||
}
|
||||
|
||||
async #result(
|
||||
entry: ManagedTaskEntry,
|
||||
requestedTailBytes: number | undefined,
|
||||
reused: boolean,
|
||||
): Promise<ManagedTaskResult> {
|
||||
const state = await this.#processManager.read(entry.sessionId, {
|
||||
maxOutputBytes: STATUS_OUTPUT_BYTES,
|
||||
});
|
||||
const tailBytes = Math.max(
|
||||
MIN_TAIL_BYTES,
|
||||
Math.min(requestedTailBytes ?? this.#options.defaultTailBytes, this.#options.maxTailBytes),
|
||||
);
|
||||
const { tail, logBytes } = this.#readTail(entry.logPath, tailBytes);
|
||||
const importantLines = this.#importantLines(entry);
|
||||
const completed = !state.running;
|
||||
const succeeded = completed
|
||||
? state.exitCode === 0 && !state.timedOut && state.error === undefined
|
||||
: undefined;
|
||||
const status: ManagedTaskResult["status"] = state.running
|
||||
? "running"
|
||||
: state.timedOut
|
||||
? "timed_out"
|
||||
: state.signal && state.exitCode !== 0
|
||||
? "cancelled"
|
||||
: succeeded
|
||||
? "succeeded"
|
||||
: "failed";
|
||||
|
||||
return {
|
||||
taskKey: entry.taskKey,
|
||||
sessionId: entry.sessionId,
|
||||
command: state.command,
|
||||
cwd: state.cwd,
|
||||
running: state.running,
|
||||
completed,
|
||||
status,
|
||||
pid: state.pid,
|
||||
startedAt: state.startedAt,
|
||||
endedAt: state.endedAt,
|
||||
wallTimeMs: state.wallTimeMs,
|
||||
exitCode: state.exitCode,
|
||||
signal: state.signal,
|
||||
timedOut: state.timedOut,
|
||||
error: state.error,
|
||||
succeeded,
|
||||
logPath: entry.logPath,
|
||||
logBytes,
|
||||
tail,
|
||||
importantLines,
|
||||
reused,
|
||||
};
|
||||
}
|
||||
|
||||
#captureImportantLines(
|
||||
importantLines: string[],
|
||||
pendingLines: Record<ProcessOutputStream, string>,
|
||||
stream: ProcessOutputStream,
|
||||
data: Buffer,
|
||||
): void {
|
||||
const combined = pendingLines[stream] + data.toString("utf8");
|
||||
const lines = combined.split(/\r?\n/);
|
||||
const remainder = lines.pop() ?? "";
|
||||
pendingLines[stream] = remainder.slice(-MAX_PENDING_LINE_CHARS);
|
||||
|
||||
for (const line of lines) {
|
||||
this.#rememberImportantLine(importantLines, line);
|
||||
}
|
||||
}
|
||||
|
||||
#importantLines(entry: ManagedTaskEntry): string[] {
|
||||
const importantLines = [...entry.importantLines];
|
||||
for (const pending of Object.values(entry.pendingLines)) {
|
||||
this.#rememberImportantLine(importantLines, pending);
|
||||
}
|
||||
return importantLines.slice(-IMPORTANT_LINE_LIMIT);
|
||||
}
|
||||
|
||||
#rememberImportantLine(importantLines: string[], line: string): void {
|
||||
const normalized = line.trim();
|
||||
if (!normalized || !IMPORTANT_LINE.test(normalized)) {
|
||||
return;
|
||||
}
|
||||
importantLines.push(normalized);
|
||||
if (importantLines.length > IMPORTANT_LINE_LIMIT) {
|
||||
importantLines.splice(0, importantLines.length - IMPORTANT_LINE_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
#readTail(logPath: string, tailBytes: number): { tail: string; logBytes: number } {
|
||||
let size: number;
|
||||
try {
|
||||
size = statSync(logPath).size;
|
||||
} catch {
|
||||
return { tail: "", logBytes: 0 };
|
||||
}
|
||||
if (size === 0) {
|
||||
return { tail: "", logBytes: 0 };
|
||||
}
|
||||
|
||||
const bytesToRead = Math.min(size, tailBytes + 4);
|
||||
const start = Math.max(0, size - bytesToRead);
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
const fd = openSync(logPath, "r");
|
||||
try {
|
||||
readSync(fd, buffer, 0, bytesToRead, start);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
|
||||
let bounded = start > 0 ? skipUtf8ContinuationBytes(buffer) : buffer;
|
||||
if (bounded.length > tailBytes) {
|
||||
bounded = skipUtf8ContinuationBytes(bounded.subarray(bounded.length - tailBytes));
|
||||
}
|
||||
return { tail: bounded.toString("utf8"), logBytes: size };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { FileService } from "./file-service.js";
|
||||
import { ManagedTaskManager } from "./managed-task-manager.js";
|
||||
import { runTool } from "./tool-result.js";
|
||||
import { TOOL_ANNOTATIONS, toolAuthMetadata } from "./tool-metadata.js";
|
||||
|
||||
export function registerManagedTaskTools(
|
||||
server: McpServer,
|
||||
config: AppConfig,
|
||||
taskManager: ManagedTaskManager,
|
||||
fileService: FileService,
|
||||
): void {
|
||||
const authMetadata = toolAuthMetadata(config);
|
||||
const maxTailBytes = Math.max(1024, Math.min(config.maxOutputBytes, 256 * 1024));
|
||||
const taskKeySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
|
||||
.describe(
|
||||
"Stable caller-chosen task key used to recover or deduplicate work, for example root-ci or backend-tests.",
|
||||
);
|
||||
const environmentSchema = z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Environment variables added to or overriding the server process environment.");
|
||||
const tailBytesSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1024)
|
||||
.max(maxTailBytes)
|
||||
.default(Math.min(32 * 1024, maxTailBytes))
|
||||
.describe("Maximum bytes from the end of the full task log included in the response.");
|
||||
|
||||
server.registerTool(
|
||||
"start_managed_task",
|
||||
{
|
||||
title: "Start managed task",
|
||||
description:
|
||||
"Start a non-interactive long-running shell task with a stable task key, persistent full log, bounded response, and duplicate-run protection. Repeating the same retained taskKey with the same command/cwd returns the existing task instead of starting it again.",
|
||||
inputSchema: {
|
||||
taskKey: taskKeySchema,
|
||||
cmd: z.string().min(1).describe("Shell command or script to execute."),
|
||||
workdir: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(`Working directory. Relative paths resolve from ${config.defaultCwd}.`),
|
||||
shell: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(`Shell executable. Defaults to ${config.defaultShell}.`),
|
||||
login: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Use login-shell semantics (-lc) instead of -c."),
|
||||
env: environmentSchema,
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Milliseconds before the task is terminated. Zero disables the task timeout.",
|
||||
),
|
||||
initialWaitMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(5000)
|
||||
.default(1000)
|
||||
.describe(
|
||||
"Brief initial wait for quick tasks to finish. Long tasks return after this interval and continue under the same task key.",
|
||||
),
|
||||
tailBytes: tailBytesSchema,
|
||||
restartCompleted: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"When true, explicitly start a fresh run if the retained task has already completed. Running tasks are never restarted.",
|
||||
),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentOpen,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({
|
||||
taskKey,
|
||||
cmd,
|
||||
workdir,
|
||||
shell,
|
||||
login,
|
||||
env,
|
||||
timeoutMs,
|
||||
initialWaitMs,
|
||||
tailBytes,
|
||||
restartCompleted,
|
||||
}) =>
|
||||
runTool(async () => {
|
||||
const cwd = fileService.resolve(".", workdir);
|
||||
const executable = shell || config.defaultShell;
|
||||
return taskManager.start({
|
||||
taskKey,
|
||||
executable,
|
||||
args: [login ? "-lc" : "-c", cmd],
|
||||
commandForDisplay: cmd,
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs,
|
||||
initialWaitMs,
|
||||
tailBytes,
|
||||
restartCompleted,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"read_managed_task",
|
||||
{
|
||||
title: "Read managed task",
|
||||
description:
|
||||
"Recover a managed task by stable task key, optionally wait for completion, and return compact status, important diagnostic lines, and a bounded tail of its full on-disk log.",
|
||||
inputSchema: {
|
||||
taskKey: taskKeySchema,
|
||||
waitMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(300_000)
|
||||
.default(30_000)
|
||||
.describe("How long to wait for task completion before returning current state."),
|
||||
tailBytes: tailBytesSchema,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ taskKey, waitMs, tailBytes }) =>
|
||||
runTool(() => taskManager.read(taskKey, { waitMs, tailBytes })),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_managed_tasks",
|
||||
{
|
||||
title: "List managed tasks",
|
||||
description:
|
||||
"List running and recently retained managed tasks by stable task key so interrupted conversations can recover work without re-executing it.",
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async () => runTool(async () => ({ tasks: await taskManager.list() })),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"cancel_managed_task",
|
||||
{
|
||||
title: "Cancel managed task",
|
||||
description:
|
||||
"Terminate a managed task by stable task key. SIGINT and SIGTERM may escalate to SIGKILL after the grace period through the existing process manager.",
|
||||
inputSchema: {
|
||||
taskKey: taskKeySchema,
|
||||
signal: z
|
||||
.enum(["SIGINT", "SIGTERM", "SIGKILL"])
|
||||
.default("SIGTERM")
|
||||
.describe("Signal sent to the managed task process tree."),
|
||||
graceMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60_000)
|
||||
.default(3000)
|
||||
.describe(
|
||||
"For SIGINT or SIGTERM, milliseconds before SIGKILL escalation; zero disables escalation.",
|
||||
),
|
||||
tailBytes: tailBytesSchema,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ taskKey, signal, graceMs, tailBytes }) =>
|
||||
runTool(() => taskManager.cancel(taskKey, signal, graceMs, tailBytes)),
|
||||
);
|
||||
}
|
||||
+30
-12
@@ -1,30 +1,42 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { registerExecTools } from "./exec-tools.js";
|
||||
import { FileService } from "./file-service.js";
|
||||
import { registerFileTools } from "./file-tools.js";
|
||||
import { ManagedTaskManager } from "./managed-task-manager.js";
|
||||
import { registerManagedTaskTools } from "./managed-task-tools.js";
|
||||
import { ProcessManager } from "./process-manager.js";
|
||||
|
||||
export interface McpServices {
|
||||
processManager: ProcessManager;
|
||||
managedTaskManager: ManagedTaskManager;
|
||||
fileService: FileService;
|
||||
}
|
||||
|
||||
export function createServices(config: AppConfig): McpServices {
|
||||
const processManager = new ProcessManager({
|
||||
maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes,
|
||||
processRetentionMs: config.processRetentionMs,
|
||||
maxProcesses: config.maxProcesses,
|
||||
defaultMaxOutputBytes: config.maxOutputBytes,
|
||||
});
|
||||
const fileService = new FileService({
|
||||
defaultCwd: config.defaultCwd,
|
||||
maxChunkBytes: config.maxFileChunkBytes,
|
||||
maxEditFileBytes: config.maxEditFileBytes,
|
||||
maxOutputBytes: config.maxOutputBytes,
|
||||
});
|
||||
return {
|
||||
processManager: new ProcessManager({
|
||||
maxRetainedOutputBytes: config.maxRetainedProcessOutputBytes,
|
||||
processRetentionMs: config.processRetentionMs,
|
||||
maxProcesses: config.maxProcesses,
|
||||
defaultMaxOutputBytes: config.maxOutputBytes,
|
||||
}),
|
||||
fileService: new FileService({
|
||||
defaultCwd: config.defaultCwd,
|
||||
maxChunkBytes: config.maxFileChunkBytes,
|
||||
maxEditFileBytes: config.maxEditFileBytes,
|
||||
maxOutputBytes: config.maxOutputBytes,
|
||||
processManager,
|
||||
managedTaskManager: new ManagedTaskManager(processManager, {
|
||||
logDirectory: path.join(os.tmpdir(), "cokacremote-managed-tasks"),
|
||||
defaultTailBytes: Math.min(32 * 1024, config.maxOutputBytes),
|
||||
maxTailBytes: Math.min(256 * 1024, config.maxOutputBytes),
|
||||
}),
|
||||
fileService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +48,7 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe
|
||||
},
|
||||
{
|
||||
instructions:
|
||||
"This server is an unrestricted remote development environment. Tools operate directly on the host with the MCP service process's full OS permissions. Use exec_command for shell, build, test, package, Git, service, and log workflows; run_script for complete Bash, Node.js, or Python scripts; and the file tools for direct file operations. Poll long-running commands with read_process or write_stdin.",
|
||||
"This server is an unrestricted remote development environment. Tools operate directly on the host with the MCP service process's full OS permissions. Prefer start_managed_task/read_managed_task for long-running non-interactive builds, tests, package installs, and similar work because stable task keys prevent accidental duplicate execution after response interruptions and responses stay compact. Use exec_command/read_process/write_stdin for low-level or interactive process control, run_script for complete scripts, and file tools for direct file operations.",
|
||||
capabilities: { logging: {} },
|
||||
},
|
||||
);
|
||||
@@ -47,6 +59,12 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe
|
||||
services.processManager,
|
||||
services.fileService,
|
||||
);
|
||||
registerManagedTaskTools(
|
||||
server,
|
||||
config,
|
||||
services.managedTaskManager,
|
||||
services.fileService,
|
||||
);
|
||||
registerFileTools(server, config, services.fileService);
|
||||
return server;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface ManagedProcess {
|
||||
timeoutHandle: NodeJS.Timeout | undefined;
|
||||
retentionHandle: NodeJS.Timeout | undefined;
|
||||
cleanup: (() => Promise<void>) | undefined;
|
||||
onOutput: ((stream: ProcessOutputStream, data: Buffer) => void) | undefined;
|
||||
}
|
||||
|
||||
function isContinuationByte(value: number): boolean {
|
||||
@@ -141,6 +142,7 @@ export interface StartProcessRequest {
|
||||
timeoutMs?: number | undefined;
|
||||
stdin?: string | undefined;
|
||||
cleanup?: (() => Promise<void>) | undefined;
|
||||
onOutput?: ((stream: ProcessOutputStream, data: Buffer) => void) | undefined;
|
||||
}
|
||||
|
||||
export interface ReadProcessRequest {
|
||||
@@ -220,6 +222,7 @@ export class ProcessManager {
|
||||
timeoutHandle: undefined,
|
||||
retentionHandle: undefined,
|
||||
cleanup: request.cleanup,
|
||||
onOutput: request.onOutput,
|
||||
};
|
||||
this.#processes.set(sessionId, managed);
|
||||
|
||||
@@ -496,6 +499,13 @@ export class ProcessManager {
|
||||
stream: ProcessOutputStream,
|
||||
data: Buffer,
|
||||
): void {
|
||||
if (managed.onOutput) {
|
||||
try {
|
||||
managed.onOutput(stream, data);
|
||||
} catch (error) {
|
||||
managed.error ??= `Output observer failed: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
managed.totalOutputBytes += data.length;
|
||||
const pending = managed.pendingOutput[stream];
|
||||
const combined = pending.length > 0 ? Buffer.concat([pending, data]) : data;
|
||||
|
||||
@@ -14,20 +14,24 @@ import { createServices } from "../src/mcp-server.js";
|
||||
|
||||
const ALL_TOOLS = [
|
||||
"apply_patch",
|
||||
"cancel_managed_task",
|
||||
"chmod_path",
|
||||
"copy_path",
|
||||
"download_file",
|
||||
"exec_command",
|
||||
"hash_file",
|
||||
"list_directory",
|
||||
"list_managed_tasks",
|
||||
"list_processes",
|
||||
"make_directory",
|
||||
"move_path",
|
||||
"read_file",
|
||||
"read_managed_task",
|
||||
"read_process",
|
||||
"remove_path",
|
||||
"replace_in_file",
|
||||
"run_script",
|
||||
"start_managed_task",
|
||||
"stat_path",
|
||||
"terminate_process",
|
||||
"upload_file",
|
||||
@@ -40,20 +44,24 @@ type ToolResult = Awaited<ReturnType<Client["callTool"]>>;
|
||||
|
||||
const EXPECTED_ANNOTATIONS = {
|
||||
apply_patch: [false, true, false, false],
|
||||
cancel_managed_task: [false, true, false, false],
|
||||
chmod_path: [false, true, true, false],
|
||||
copy_path: [false, true, true, false],
|
||||
download_file: [true, false, true, false],
|
||||
exec_command: [false, true, false, true],
|
||||
hash_file: [true, false, true, false],
|
||||
list_directory: [true, false, true, false],
|
||||
list_managed_tasks: [true, false, true, false],
|
||||
list_processes: [true, false, true, false],
|
||||
make_directory: [false, false, true, false],
|
||||
move_path: [false, true, true, false],
|
||||
read_file: [true, false, true, false],
|
||||
read_managed_task: [true, false, true, false],
|
||||
read_process: [true, false, true, false],
|
||||
remove_path: [false, true, true, false],
|
||||
replace_in_file: [false, true, false, false],
|
||||
run_script: [false, true, false, true],
|
||||
start_managed_task: [false, true, false, true],
|
||||
stat_path: [true, false, true, false],
|
||||
terminate_process: [false, true, false, false],
|
||||
upload_file: [false, true, true, false],
|
||||
@@ -336,6 +344,56 @@ describe.sequential("all registered MCP tools", () => {
|
||||
waitMs: 2000,
|
||||
});
|
||||
expect(terminated).toMatchObject({ running: false, completed: true, signal: "SIGTERM" });
|
||||
|
||||
const managed = await callOk("start_managed_task", {
|
||||
taskKey: "e2e-managed-task",
|
||||
cmd: "printf 'managed-start\n'; sleep 0.1; printf 'BUILD SUCCESSFUL\n'",
|
||||
workdir: testRoot,
|
||||
initialWaitMs: 0,
|
||||
});
|
||||
expect(managed).toMatchObject({
|
||||
taskKey: "e2e-managed-task",
|
||||
running: true,
|
||||
reused: false,
|
||||
});
|
||||
const reusedManaged = await callOk("start_managed_task", {
|
||||
taskKey: "e2e-managed-task",
|
||||
cmd: "printf 'managed-start\n'; sleep 0.1; printf 'BUILD SUCCESSFUL\n'",
|
||||
workdir: testRoot,
|
||||
initialWaitMs: 0,
|
||||
});
|
||||
expect(reusedManaged).toMatchObject({
|
||||
sessionId: managed.sessionId,
|
||||
reused: true,
|
||||
});
|
||||
const listedManaged = await callOk("list_managed_tasks");
|
||||
expect(listedManaged.tasks).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ taskKey: "e2e-managed-task" }),
|
||||
]),
|
||||
);
|
||||
const finishedManaged = await callOk("read_managed_task", {
|
||||
taskKey: "e2e-managed-task",
|
||||
waitMs: 3000,
|
||||
});
|
||||
expect(finishedManaged).toMatchObject({
|
||||
completed: true,
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(String(finishedManaged.tail)).toContain("BUILD SUCCESSFUL");
|
||||
|
||||
await callOk("start_managed_task", {
|
||||
taskKey: "e2e-managed-cancel",
|
||||
cmd: "sleep 10",
|
||||
workdir: testRoot,
|
||||
initialWaitMs: 0,
|
||||
});
|
||||
await callOk("cancel_managed_task", {
|
||||
taskKey: "e2e-managed-cancel",
|
||||
signal: "SIGTERM",
|
||||
graceMs: 1000,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("handles text, metadata, listings, permissions, and unified patches", async () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ManagedTaskManager } from "../src/managed-task-manager.js";
|
||||
import { ProcessManager } from "../src/process-manager.js";
|
||||
|
||||
function createProcessManager(): ProcessManager {
|
||||
return new ProcessManager({
|
||||
maxRetainedOutputBytes: 32 * 1024,
|
||||
processRetentionMs: 60_000,
|
||||
maxProcesses: 16,
|
||||
defaultMaxOutputBytes: 64 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ManagedTaskManager", () => {
|
||||
let temporaryDirectory: string;
|
||||
let processManager: ProcessManager;
|
||||
let tasks: ManagedTaskManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "cokacremote-managed-task-test-"));
|
||||
processManager = createProcessManager();
|
||||
tasks = new ManagedTaskManager(processManager, {
|
||||
logDirectory: path.join(temporaryDirectory, "logs"),
|
||||
defaultTailBytes: 4096,
|
||||
maxTailBytes: 16 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await processManager.shutdown();
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("reuses a retained task key instead of executing the same work twice", async () => {
|
||||
const command = "printf run >> marker.txt; sleep 0.2; printf 'done\\n'";
|
||||
const request = {
|
||||
taskKey: "duplicate-protected",
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", command],
|
||||
commandForDisplay: command,
|
||||
cwd: temporaryDirectory,
|
||||
initialWaitMs: 0,
|
||||
};
|
||||
|
||||
const first = await tasks.start(request);
|
||||
const second = await tasks.start(request);
|
||||
|
||||
expect(first.reused).toBe(false);
|
||||
expect(second.reused).toBe(true);
|
||||
expect(second.sessionId).toBe(first.sessionId);
|
||||
|
||||
await processManager.waitForExit(first.sessionId, 2000);
|
||||
const completed = await tasks.read("duplicate-protected");
|
||||
expect(completed).toMatchObject({ running: false, completed: true, exitCode: 0 });
|
||||
expect(await readFile(path.join(temporaryDirectory, "marker.txt"), "utf8")).toBe("run");
|
||||
});
|
||||
|
||||
it("stores complete output while returning only a bounded diagnostic tail", async () => {
|
||||
const command =
|
||||
"node -e \"process.stdout.write('x'.repeat(100000)); process.stderr.write('\\nERROR final-marker\\n')\"";
|
||||
const started = await tasks.start({
|
||||
taskKey: "large-output",
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", command],
|
||||
commandForDisplay: command,
|
||||
cwd: temporaryDirectory,
|
||||
initialWaitMs: 2000,
|
||||
});
|
||||
|
||||
expect(started.completed).toBe(true);
|
||||
const result = await tasks.read("large-output", { tailBytes: 4096 });
|
||||
const completeLog = await readFile(result.logPath, "utf8");
|
||||
|
||||
expect(completeLog.length).toBeGreaterThan(100_000);
|
||||
expect(result.tail.length).toBeLessThanOrEqual(4096);
|
||||
expect(result.tail).toContain("ERROR final-marker");
|
||||
expect(result.importantLines).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("ERROR final-marker")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps important lines from the full log even when they fall outside the returned tail", async () => {
|
||||
const command =
|
||||
"node -e \"console.log('ERROR early-marker'); process.stdout.write('x'.repeat(100000))\"";
|
||||
await tasks.start({
|
||||
taskKey: "early-error",
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", command],
|
||||
commandForDisplay: command,
|
||||
cwd: temporaryDirectory,
|
||||
initialWaitMs: 2000,
|
||||
});
|
||||
|
||||
const result = await tasks.read("early-error", { tailBytes: 4096 });
|
||||
|
||||
expect(result.tail).not.toContain("ERROR early-marker");
|
||||
expect(result.importantLines).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining("ERROR early-marker")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses completed work by default and reruns only when explicitly requested", async () => {
|
||||
const command = "printf x >> restart-marker.txt";
|
||||
const request = {
|
||||
taskKey: "restartable",
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", command],
|
||||
commandForDisplay: command,
|
||||
cwd: temporaryDirectory,
|
||||
initialWaitMs: 2000,
|
||||
};
|
||||
|
||||
const first = await tasks.start(request);
|
||||
const reused = await tasks.start(request);
|
||||
const restarted = await tasks.start({ ...request, restartCompleted: true });
|
||||
|
||||
expect(first.completed).toBe(true);
|
||||
expect(reused).toMatchObject({ reused: true, sessionId: first.sessionId });
|
||||
expect(restarted.reused).toBe(false);
|
||||
expect(restarted.sessionId).not.toBe(first.sessionId);
|
||||
expect(await readFile(path.join(temporaryDirectory, "restart-marker.txt"), "utf8")).toBe(
|
||||
"xx",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists tasks by stable key and can cancel a running task", async () => {
|
||||
const command = "printf started; sleep 10";
|
||||
const started = await tasks.start({
|
||||
taskKey: "recover-me",
|
||||
executable: "/bin/bash",
|
||||
args: ["-c", command],
|
||||
commandForDisplay: command,
|
||||
cwd: temporaryDirectory,
|
||||
initialWaitMs: 0,
|
||||
});
|
||||
|
||||
expect(await tasks.list()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
taskKey: "recover-me",
|
||||
sessionId: started.sessionId,
|
||||
running: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await tasks.cancel("recover-me", "SIGTERM", 20);
|
||||
await processManager.waitForExit(started.sessionId, 2000);
|
||||
const cancelled = await tasks.read("recover-me");
|
||||
expect(cancelled.running).toBe(false);
|
||||
expect(cancelled.completed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ describe("tool authentication metadata", () => {
|
||||
MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp",
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(20);
|
||||
expect(tools).toHaveLength(24);
|
||||
for (const tool of tools) {
|
||||
expect(tool._meta, tool.name).toEqual({
|
||||
securitySchemes: [{ type: "oauth2", scopes: ["mcp:tools"] }],
|
||||
@@ -48,7 +48,7 @@ describe("tool authentication metadata", () => {
|
||||
it("does not infer noauth from the internal authentication bypass", async () => {
|
||||
const tools = await listTools({ MCP_ALLOW_NO_AUTH: "true" });
|
||||
|
||||
expect(tools).toHaveLength(20);
|
||||
expect(tools).toHaveLength(24);
|
||||
for (const tool of tools) {
|
||||
expect(tool._meta, tool.name).toBeUndefined();
|
||||
}
|
||||
@@ -57,7 +57,7 @@ describe("tool authentication metadata", () => {
|
||||
it("does not mislabel static bearer authentication as noauth or OAuth", async () => {
|
||||
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
|
||||
|
||||
expect(tools).toHaveLength(20);
|
||||
expect(tools).toHaveLength(24);
|
||||
for (const tool of tools) {
|
||||
expect(tool._meta, tool.name).toBeUndefined();
|
||||
}
|
||||
@@ -81,7 +81,7 @@ describe("client-facing metadata accuracy", () => {
|
||||
it("describes every tool and every input field", async () => {
|
||||
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
|
||||
|
||||
expect(tools).toHaveLength(20);
|
||||
expect(tools).toHaveLength(24);
|
||||
for (const tool of tools) {
|
||||
expect(tool.title?.trim().length, tool.name).toBeGreaterThan(0);
|
||||
expect(tool.description?.trim().length, tool.name).toBeGreaterThan(0);
|
||||
|
||||
Reference in New Issue
Block a user