Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ the package manager that supplied `installedPackage`.
| `KNAP_RECONNECT_MS` | `2000` | Telemetry reconnect delay |

Knapper writes default-profile telemetry to `KNAP_HOME/telemetry/events.jsonl`.
Each managed session uses the shared telemetry file in that directory. Knapper
The managed session uses `KNAP_HOME/telemetry/session.jsonl`. Knapper
writes redacted tool audit events under `KNAP_HOME/audit`. Audit files use mode
`0600` and have 14-day retention.

Session release and reset operations archive telemetry records. They store retained
records in the managed or quarantined root.
Session reset archives its telemetry in the quarantined root. Session release keeps
the telemetry file ready for the next agent that claims the same session.

`LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are supported aliases. The
`KNAP_` name takes precedence.
Expand Down
168 changes: 96 additions & 72 deletions src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,86 +371,110 @@ export class ToolRegistry {
const callRequestId = requestId(requestContext);
const callArgs = args ?? {};
let queueMs = 0;
let admitted = false;
let auditContext: AuditCallContext | undefined;
let auditOutcome: "success" | "error" = "success";
let auditError: AuditErrorEnvelope | undefined;
let completedOutcome: ToolOutcome | UobError | undefined;
return this.lock.run("exclusive", def.name, async () => {
try {
// Read the telemetry cursor after admission, not before: a call that
// waited in the queue would otherwise report every log line produced
// by the calls it was queued behind.
const sinceSeq = this.telemetry?.cursor ?? 0;
const ranAt = Date.now();
admitted = true;
queueMs = ranAt - started;
await this.hooks.beforeInvoke?.(def, callArgs, requestContext);
auditContext = await this.hooks.contextProvider?.(callArgs, requestContext);
let outcome = await def.handler(callArgs);
completedOutcome = outcome;
if (
this.telemetry &&
def.annotations?.readOnlyHint !== true &&
def.handlesOwnTelemetry !== true
) {
outcome = withTelemetrySuffix(outcome, this.telemetry, sinceSeq);
}
this.logger.debug("tool ok", {
tool: def.name,
ms: Date.now() - ranAt,
queuedMs: ranAt - started,
});
const result = normalize(outcome);
if (result.isError === true) {
try {
return await this.lock.run("exclusive", def.name, async () => {
try {
// Read the telemetry cursor after admission, not before: a call that
// waited in the queue would otherwise report every log line produced
// by the calls it was queued behind.
const sinceSeq = this.telemetry?.cursor ?? 0;
const ranAt = Date.now();
queueMs = ranAt - started;
await this.hooks.beforeInvoke?.(def, callArgs, requestContext);
auditContext = await this.hooks.contextProvider?.(callArgs, requestContext);
let outcome = await def.handler(callArgs);
completedOutcome = outcome;
if (
this.telemetry &&
def.annotations?.readOnlyHint !== true &&
def.handlesOwnTelemetry !== true
) {
outcome = withTelemetrySuffix(outcome, this.telemetry, sinceSeq);
}
this.logger.debug("tool ok", {
tool: def.name,
ms: Date.now() - ranAt,
queuedMs: ranAt - started,
});
const result = normalize(outcome);
if (result.isError === true) {
auditOutcome = "error";
auditError = {
type: "ToolResultError",
code: "TOOL_ERROR",
message: "The tool call failed.",
retriable: false,
};
}
return result;
} catch (e) {
const err = toUobError(e);
completedOutcome = err;
auditOutcome = "error";
auditError = {
type: "ToolResultError",
code: "TOOL_ERROR",
message: "The tool call failed.",
retriable: false,
};
}
return result;
} catch (e) {
const err = toUobError(e);
completedOutcome = err;
auditOutcome = "error";
auditError = errorEnvelope(err);
this.logger.warn("tool failed", {
tool: def.name,
code: err.code,
ms: Date.now() - started,
});
return errorResult(err);
} finally {
if (completedOutcome !== undefined) {
try {
await this.hooks.afterInvoke?.(def, callArgs, requestContext, completedOutcome);
} catch (hookError) {
this.logger.warn("afterInvoke hook failed", {
tool: def.name,
error: hookError instanceof Error ? hookError.name : "UnknownHookError",
});
auditError = errorEnvelope(err);
this.logger.warn("tool failed", {
tool: def.name,
code: err.code,
ms: Date.now() - started,
});
return errorResult(err);
} finally {
if (completedOutcome !== undefined) {
try {
await this.hooks.afterInvoke?.(def, callArgs, requestContext, completedOutcome);
} catch (hookError) {
this.logger.warn("afterInvoke hook failed", {
tool: def.name,
error: hookError instanceof Error ? hookError.name : "UnknownHookError",
});
}
}
if (this.audit !== false) {
this.queueAudit(
toolAuditEvent({
timestamp,
requestId: callRequestId,
tool: def.name,
durationMs: Date.now() - started,
queueMs,
outcome: auditOutcome,
args: callArgs,
...(auditContext ? { context: auditContext } : {}),
...(auditError ? { error: auditError } : {}),
}),
);
}
}
if (this.audit !== false) {
this.queueAudit(
toolAuditEvent({
timestamp,
requestId: callRequestId,
tool: def.name,
durationMs: Date.now() - started,
queueMs: admitted ? queueMs : Date.now() - started,
outcome: auditOutcome,
args: callArgs,
...(auditContext ? { context: auditContext } : {}),
...(auditError ? { error: auditError } : {}),
}),
);
}
});
} catch (e) {
const err = toUobError(e);
auditOutcome = "error";
auditError = errorEnvelope(err);
this.logger.warn("tool failed before admission", {
tool: def.name,
code: err.code,
ms: Date.now() - started,
});
if (this.audit !== false) {
this.queueAudit(
toolAuditEvent({
timestamp,
requestId: callRequestId,
tool: def.name,
durationMs: Date.now() - started,
queueMs: Date.now() - started,
outcome: auditOutcome,
args: callArgs,
error: auditError,
}),
);
}
});
return errorResult(err);
}
}) as never,
);
}
Expand Down
57 changes: 43 additions & 14 deletions src/tools/session.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,11 @@ function compatible(
return true;
}

async function singletonDescriptor(
export function selectSingletonDescriptor(
descriptors: SessionDescriptor[],
pluginSourceDir?: string,
pluginId?: string,
): Promise<SessionDescriptor | undefined> {
const descriptors = (await listDescriptors()).filter(
(descriptor) => descriptor.readiness.phase !== "failed",
);
): SessionDescriptor | undefined {
if (descriptors.length === 0) return undefined;
const descriptor = descriptors.at(-1) as SessionDescriptor;
if (!compatible(descriptor, pluginSourceDir, pluginId)) {
Expand All @@ -61,22 +59,35 @@ async function singletonDescriptor(
return descriptor;
}

async function singletonDescriptor(
pluginSourceDir?: string,
pluginId?: string,
): Promise<SessionDescriptor | undefined> {
return selectSingletonDescriptor(await listDescriptors(), pluginSourceDir, pluginId);
}

async function makeReady(
ctx: ServerContext,
descriptor: SessionDescriptor,
timeoutMs?: number,
deadline?: number,
): Promise<SessionDescriptor> {
const remainingOptions = (): { timeoutMs?: number } =>
deadline === undefined ? {} : { timeoutMs: Math.max(1, deadline - Date.now()) };
let next = descriptor;
if (next.readiness.phase === "starting") {
next = await waitSession(next.key, timeoutMs !== undefined ? { timeoutMs } : {});
} else if (next.readiness.phase === "stopped" || (await sessionState(next)) !== "live") {
next = await waitSession(next.key, remainingOptions());
} else if (
next.readiness.phase === "failed" ||
next.readiness.phase === "stopped" ||
(await sessionState(next)) !== "live"
) {
const restarted = await restartSession(next.key, {
logger: ctx.logger.child("session"),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...remainingOptions(),
});
next = restarted.descriptor;
if (next.readiness.phase === "starting") {
next = await waitSession(next.key, timeoutMs !== undefined ? { timeoutMs } : {});
next = await waitSession(next.key, remainingOptions());
}
}
await ctx.bindSession(next);
Expand All @@ -87,11 +98,16 @@ async function makeReady(
async function openIsolated(
ctx: ServerContext,
args: Record<string, unknown>,
inheritedDeadline?: number,
): Promise<SessionDescriptor> {
const pluginSourceDir =
typeof args.pluginSourceDir === "string" ? args.pluginSourceDir : undefined;
const pluginId = typeof args.pluginId === "string" ? args.pluginId : undefined;
const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : undefined;
const deadline =
inheritedDeadline ?? (timeoutMs === undefined ? undefined : Date.now() + timeoutMs);
const timeoutOptions = (): { timeoutMs?: number } =>
deadline === undefined ? {} : { timeoutMs: Math.max(1, deadline - Date.now()) };
let descriptor = await singletonDescriptor(pluginSourceDir, pluginId);
if (descriptor === undefined) {
descriptor = await createSession({
Expand All @@ -100,10 +116,10 @@ async function openIsolated(
...(typeof args.label === "string" ? { label: args.label } : {}),
...(pluginSourceDir !== undefined ? { pluginSourceDir } : {}),
...(pluginId !== undefined ? { pluginId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...timeoutOptions(),
});
}
return makeReady(ctx, descriptor, timeoutMs);
return makeReady(ctx, descriptor, deadline);
}

export function registerSessionTools(ctx: ServerContext): void {
Expand Down Expand Up @@ -240,11 +256,19 @@ export function registerSessionTools(ctx: ServerContext): void {
},
handler: async (args) => {
const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : undefined;
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
const remainingTimeout = (): number | undefined =>
deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
const descriptors = await listDescriptors();
const previous = ctx.currentSessionKey ?? descriptors.at(-1)?.key;
let quarantinedPath: string | undefined;
let archivedTelemetry: string | undefined;
if (previous !== undefined) {
const stopped = await stopSession(previous, timeoutMs !== undefined ? { timeoutMs } : {});
const stopTimeout = remainingTimeout();
const stopped = await stopSession(
previous,
stopTimeout !== undefined ? { timeoutMs: stopTimeout } : {},
);
if (stopped.state === "quitFailed") {
throw new UobError("TIMEOUT", `Session ${previous} did not stop.`, {
remediation: "Retry after the managed Obsidian process stops.",
Expand All @@ -255,12 +279,17 @@ export function registerSessionTools(ctx: ServerContext): void {
await ctx.bindDefault();
ctx.currentSessionKey = undefined;
ctx.targetKind = undefined;
const descriptor = await openIsolated(ctx, args);
ctx.selectTelemetry("default");
if (quarantinedPath !== undefined) {
archivedTelemetry = await ctx.archiveTelemetry("session", quarantinedPath);
}
const descriptor = await openIsolated(ctx, args, deadline);
return {
text: `Fresh isolated session ${descriptor.key} is ready.`,
json: {
reset: previous ?? null,
quarantinedPath: quarantinedPath ?? null,
archivedTelemetry: archivedTelemetry ?? null,
...publicSummary(descriptor),
},
};
Expand Down
14 changes: 9 additions & 5 deletions src/usage/activity-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ export class ActivityGuard {
this.now = opts.now ?? (() => new Date());
this.pid = opts.pid ?? process.pid;
this.host = opts.hostname ?? hostname();
if (!Number.isFinite(opts.idleTimeoutMs) || opts.idleTimeoutMs <= 0) {
throw new UobError("INVALID_ARGUMENT", "The activity idle timeout must be positive.", {
remediation: "Set KNAP_ACTIVITY_IDLE_MS to a positive number of milliseconds.",
});
if (!Number.isFinite(opts.idleTimeoutMs) || opts.idleTimeoutMs < 1_000) {
throw new UobError(
"INVALID_ARGUMENT",
"The activity idle timeout must be at least 1000 ms.",
{
remediation: "Set KNAP_ACTIVITY_IDLE_MS to at least 1000 milliseconds.",
},
);
}
}

Expand Down Expand Up @@ -118,7 +122,7 @@ export class ActivityGuard {

private startHeartbeat(): void {
if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer);
const intervalMs = Math.max(1_000, Math.floor(this.opts.idleTimeoutMs / 3));
const intervalMs = Math.max(250, Math.floor(this.opts.idleTimeoutMs / 3));
this.heartbeatTimer = setInterval(
() => void this.heartbeat().catch(() => undefined),
intervalMs,
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/settings-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
data.json
6 changes: 6 additions & 0 deletions tests/unit/activity-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ afterEach(async () => {
});

describe("ActivityGuard", () => {
it("rejects an idle window shorter than the heartbeat floor", () => {
expect(() => new ActivityGuard({ idleTimeoutMs: 999, env })).toThrow(
"activity idle timeout must be at least 1000 ms",
);
});

it("starts free, tracks an operation, and does not renew from status", async () => {
let now = new Date("2026-08-08T12:00:00Z");
const first = new ActivityGuard({
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/server-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { loadConfig } from "../../src/config.js";
import { applySessionConfig } from "../../src/server.js";
import type { SessionDescriptor } from "../../src/session/descriptor.js";
import { selectSingletonDescriptor } from "../../src/tools/session.js";

function descriptor(): SessionDescriptor {
return {
Expand Down Expand Up @@ -58,3 +59,19 @@ describe("applySessionConfig", () => {
expect(() => applySessionConfig(loadConfig({}, {}), pending)).toThrow(/not ready/);
});
});

describe("selectSingletonDescriptor", () => {
it("retains a failed descriptor so recovery cannot create a second target", () => {
const failed = descriptor();
failed.readiness = {
phase: "failed",
failedAt: "2026-08-04T12:00:02.000Z",
code: "TIMEOUT",
message: "startup verification timed out",
remediation: "Retry the managed session.",
fixedBy: "obsidian_session_reset",
};

expect(selectSingletonDescriptor([failed])).toBe(failed);
});
});