conductor-remote 1.124.0 → 1.125.1
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.
- package/dist/assets/{PierrePatch-4wYckD3t.js → PierrePatch-Sohj8IVv.js} +1 -1
- package/dist/assets/index-CJ_yLSwD.css +1 -0
- package/dist/assets/{index-D0mVtqUh.js → index-CMkZa4wP.js} +41 -41
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/contracts/agent-inputs.js +44 -0
- package/dist-node/src/contracts/validation.js +16 -0
- package/dist-node/src/http/input.js +9 -0
- package/dist-node/src/http/router.js +3 -0
- package/dist-node/src/http/routes/create-workspace.js +10 -25
- package/dist-node/src/http/routes/prompts.js +9 -17
- package/dist-node/src/http/routes/sessions.js +5 -5
- package/dist-node/src/http/services/base.js +9 -3
- package/dist-node/src/http/services/workflow-notifications.js +53 -0
- package/dist-node/src/http/services/workflow.js +19 -4
- package/dist-node/src/mcp/client.js +66 -0
- package/dist-node/src/mcp/define-tool.js +11 -0
- package/dist-node/src/mcp/tools/agent-options.js +15 -33
- package/dist-node/src/mcp/tools/chat-actions.js +14 -20
- package/dist-node/src/mcp/tools/workspaces.js +21 -43
- package/dist-node/src/mcp.js +3 -57
- package/dist-node/src/orchestration/persistence/db.js +4 -2
- package/dist-node/src/orchestration/persistence/effect-transitions.js +17 -0
- package/dist-node/src/orchestration/persistence/events.js +10 -0
- package/dist-node/src/orchestration/workflow/blocked.js +93 -0
- package/dist-node/src/orchestration/workflow/compatibility.js +42 -0
- package/dist-node/src/orchestration/workflow/effects.js +46 -1
- package/dist-node/src/orchestration/workflow/errors.js +14 -0
- package/dist-node/src/orchestration/workflow/helpers.js +2 -0
- package/dist-node/src/orchestration/workflow/job-results.js +9 -1
- package/dist-node/src/orchestration/workflow/report.js +31 -0
- package/dist-node/src/orchestration/workflow/wake.js +20 -0
- package/dist-node/src/prefs.js +2 -1
- package/dist-node/src/shared.js +5 -0
- package/dist-node/src/wire.js +2 -2
- package/docs/voice-setup.md +7 -4
- package/package.json +1 -1
- package/dist/assets/index-8RMgvUzK.css +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createWorkspaceSchema, hasAgentSettings } from "../../contracts/agent-inputs.js";
|
|
1
2
|
import { routes } from "../../routes.js";
|
|
2
3
|
import { workspaceTitle } from "../../shared.js";
|
|
3
|
-
import { need,
|
|
4
|
+
import { need, str } from "../arguments.js";
|
|
5
|
+
import { defineTool } from "../define-tool.js";
|
|
4
6
|
import { clip, formatDevServer, unmark } from "../formatters.js";
|
|
5
7
|
import { WRITE_TIMEOUT_MS } from "../protocol.js";
|
|
6
8
|
export function createListWorkspacesTool(call) {
|
|
@@ -82,51 +84,27 @@ export function createListReposTool(call) {
|
|
|
82
84
|
}
|
|
83
85
|
};
|
|
84
86
|
}
|
|
87
|
+
const createWorkspaceInputSchema = createWorkspaceSchema
|
|
88
|
+
.omit({
|
|
89
|
+
send: true,
|
|
90
|
+
sendImmediately: true,
|
|
91
|
+
attachmentIds: true
|
|
92
|
+
})
|
|
93
|
+
.extend({
|
|
94
|
+
repo: createWorkspaceSchema.shape.repo.unwrap().describe('Exact name from list_repos.'),
|
|
95
|
+
model: createWorkspaceSchema.shape.model.describe('Picker label from list_models with no session_id. The relay applies it before the first prompt, or configures an empty workspace once its chat exists.'),
|
|
96
|
+
wait_for_send: createWorkspaceSchema.shape.send,
|
|
97
|
+
send_immediately: createWorkspaceSchema.shape.sendImmediately
|
|
98
|
+
})
|
|
99
|
+
.strict();
|
|
85
100
|
export function createCreateWorkspaceTool(call) {
|
|
86
|
-
return {
|
|
101
|
+
return defineTool({
|
|
87
102
|
name: 'create_workspace',
|
|
88
103
|
description: 'Start an ordinary new Conductor workspace in a repo, optionally with a first prompt and agent settings. The workspace starts from a Conductor deep link, so creation itself needs no Accessibility. The relay applies requested model, effort, plan, and fast settings through Conductor’s UI after it creates the first chat and before it delivers the prompt. This utility cannot start or join a Workflow. Returns as soon as the workspace row exists (~2s), before the worktree is built.',
|
|
89
|
-
inputSchema:
|
|
90
|
-
type: 'object',
|
|
91
|
-
properties: {
|
|
92
|
-
repo: { type: 'string', description: 'Exact name from list_repos.' },
|
|
93
|
-
prompt: { type: 'string', description: 'First prompt for the new agent. Omit to open an empty workspace.' },
|
|
94
|
-
model: {
|
|
95
|
-
type: 'string',
|
|
96
|
-
description: 'Picker label from list_models with no session_id. The relay applies it before the first prompt, or configures an empty workspace once its chat exists.'
|
|
97
|
-
},
|
|
98
|
-
effort: {
|
|
99
|
-
type: 'string',
|
|
100
|
-
enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
|
|
101
|
-
description: 'Reasoning effort applied before the first prompt.'
|
|
102
|
-
},
|
|
103
|
-
plan: { type: 'boolean', description: 'Start the first chat in Conductor’s Plan mode.' },
|
|
104
|
-
fast: { type: 'boolean', description: 'Set Fast mode before the first prompt.' },
|
|
105
|
-
wait_for_send: {
|
|
106
|
-
type: 'boolean',
|
|
107
|
-
description: 'Block until the first prompt is actually delivered (tens of seconds, and longer behind other queued sends). Default false.'
|
|
108
|
-
},
|
|
109
|
-
send_immediately: {
|
|
110
|
-
type: 'boolean',
|
|
111
|
-
description: "Send the first prompt without waiting for the worktree to finish building, which is how Conductor's own New workspace box behaves. Default true. Pass false only when the agent's first move needs what the repo's setup script installs."
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
required: ['repo'],
|
|
115
|
-
additionalProperties: false
|
|
116
|
-
},
|
|
104
|
+
inputSchema: createWorkspaceInputSchema,
|
|
117
105
|
run: async (args) => {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const prompt = str(args.prompt);
|
|
121
|
-
const model = str(args.model);
|
|
122
|
-
const effort = str(args.effort);
|
|
123
|
-
const plan = typeof args.plan === 'boolean' ? args.plan : undefined;
|
|
124
|
-
const fast = typeof args.fast === 'boolean' ? args.fast : undefined;
|
|
125
|
-
const explicitAgent = model !== undefined || effort !== undefined || plan !== undefined || fast !== undefined;
|
|
126
|
-
const configured = explicitAgent;
|
|
127
|
-
const send = args.wait_for_send === true;
|
|
128
|
-
// Default on, matching the relay: an omitted flag must never mean "wait".
|
|
129
|
-
const sendImmediately = args.send_immediately !== false;
|
|
106
|
+
const { repo, prompt, model, effort, plan, fast, wait_for_send: send, send_immediately: sendImmediately } = args;
|
|
107
|
+
const configured = hasAgentSettings(args);
|
|
130
108
|
const data = await call(routes.createWorkspace.path(), {
|
|
131
109
|
method: routes.createWorkspace.method,
|
|
132
110
|
body: { repo, prompt, model, effort, plan, fast, send, sendImmediately },
|
|
@@ -146,7 +124,7 @@ export function createCreateWorkspaceTool(call) {
|
|
|
146
124
|
lines.push(data.configured ? 'agent settings applied' : 'agent settings queued');
|
|
147
125
|
return lines.join('\n');
|
|
148
126
|
}
|
|
149
|
-
};
|
|
127
|
+
});
|
|
150
128
|
}
|
|
151
129
|
export function createSetWorkspaceStatusTool(call) {
|
|
152
130
|
return {
|
package/dist-node/src/mcp.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
1
|
import { createInterface } from 'node:readline';
|
|
4
|
-
import {
|
|
2
|
+
import { call, relayBase } from "./mcp/client.js";
|
|
5
3
|
import { handleRpc } from "./mcp/dispatcher.js";
|
|
6
|
-
import { READ_TIMEOUT_MS } from "./mcp/protocol.js";
|
|
7
4
|
import { createTools } from "./mcp/registry.js";
|
|
8
5
|
/**
|
|
9
6
|
* MCP over stdio: `conductor-remote mcp`.
|
|
@@ -19,9 +16,8 @@ import { createTools } from "./mcp/registry.js";
|
|
|
19
16
|
*
|
|
20
17
|
* Hand-rolled rather than built on `@modelcontextprotocol/sdk`, which measures 91
|
|
21
18
|
* packages / 24 MB (express, hono, cors, jose) for a server that speaks neither HTTP
|
|
22
|
-
* nor OAuth. The relay's
|
|
23
|
-
*
|
|
24
|
-
* Mac.
|
|
19
|
+
* nor OAuth. The tool wrapper uses the relay's existing Zod dependency for shared
|
|
20
|
+
* input validation and JSON Schema export, without adding the SDK's HTTP/OAuth stack.
|
|
25
21
|
*/
|
|
26
22
|
/**
|
|
27
23
|
* stdout is the wire. A stray `console.log` from anywhere in this process would be
|
|
@@ -30,56 +26,6 @@ import { createTools } from "./mcp/registry.js";
|
|
|
30
26
|
*/
|
|
31
27
|
console.log = (...args) => console.error(...args);
|
|
32
28
|
console.info = (...args) => console.error(...args);
|
|
33
|
-
function relayBase() {
|
|
34
|
-
const port = process.env.RELAY_PORT ?? '8787';
|
|
35
|
-
// The relay binds loopback (see config.ts); RELAY_HOST only widens who else can
|
|
36
|
-
// reach it, so 127.0.0.1 is always right for a client on the same Mac.
|
|
37
|
-
return `http://127.0.0.1:${port}`;
|
|
38
|
-
}
|
|
39
|
-
function relayToken() {
|
|
40
|
-
if (process.env.RELAY_TOKEN)
|
|
41
|
-
return process.env.RELAY_TOKEN;
|
|
42
|
-
try {
|
|
43
|
-
return readFileSync(path.join(stateDir(), 'token'), 'utf8').trim();
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
throw new Error(`no relay token at ${path.join(stateDir(), 'token')} — start the relay once (conductor-remote service install), or set RELAY_TOKEN`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
async function call(route, opts = {}) {
|
|
50
|
-
const timeoutMs = opts.timeoutMs ?? READ_TIMEOUT_MS;
|
|
51
|
-
let res;
|
|
52
|
-
try {
|
|
53
|
-
res = await fetch(`${relayBase()}${route}`, {
|
|
54
|
-
method: opts.method ?? 'GET',
|
|
55
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
56
|
-
headers: {
|
|
57
|
-
authorization: `Bearer ${relayToken()}`,
|
|
58
|
-
'content-type': 'application/json',
|
|
59
|
-
// Marks this caller as an agent: the relay drops it to background priority on
|
|
60
|
-
// the UI lock, behind anyone using the phone (src/http/router.ts ▸ withUiPriority).
|
|
61
|
-
'x-relay-client': 'mcp',
|
|
62
|
-
// The relay retries a failed send inside this budget and never past it, so
|
|
63
|
-
// stating it here is what stops it outliving us — see src/http/services/delivery.ts ▸ sendBudget.
|
|
64
|
-
'x-client-timeout-ms': String(timeoutMs)
|
|
65
|
-
},
|
|
66
|
-
body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
if (err instanceof DOMException && err.name === 'TimeoutError')
|
|
71
|
-
throw new Error(`the relay did not answer within ${Math.round(timeoutMs / 1000)}s`);
|
|
72
|
-
throw new Error(`cannot reach the relay at ${relayBase()} (${err instanceof Error ? err.message : err}). Is it running? \`conductor-remote service status\``);
|
|
73
|
-
}
|
|
74
|
-
const payload = (await res.json().catch(() => ({})));
|
|
75
|
-
if (!res.ok) {
|
|
76
|
-
// 503 is the UI lock refusing a deep queue, and it is worth naming as such: it
|
|
77
|
-
// means "retry shortly", not "this failed".
|
|
78
|
-
const busy = res.status === 503 ? ' (Conductor’s UI is busy — retry shortly)' : '';
|
|
79
|
-
throw new Error(`${payload.error || `HTTP ${res.status}`}${busy}`);
|
|
80
|
-
}
|
|
81
|
-
return payload;
|
|
82
|
-
}
|
|
83
29
|
const tools = createTools(call);
|
|
84
30
|
function write(message) {
|
|
85
31
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { consumeWorkflowCapability, getWorkflowCapability, issueWorkflowCapability, revokeWorkflowCapabilities } from "./capabilities.js";
|
|
2
2
|
import { PersistenceConnection } from "./connection.js";
|
|
3
3
|
import { markWorkflowEffectReceiptLost, reconcileAbandonedWorkflowEffect, recordLateWorkflowEffect, replayAmbiguousWorkflowEffect, retryWorkflowEffect } from "./effect-recovery.js";
|
|
4
|
-
import { markWorkflowEffectAmbiguous, markWorkflowEffectCancelled, markWorkflowEffectCommitted, markWorkflowEffectDispatched, markWorkflowEffectFailed, markWorkflowEffectFailedBeforeMayExecute, markWorkflowEffectMayExecute } from "./effect-transitions.js";
|
|
4
|
+
import { markWorkflowConfigurationRejected, markWorkflowEffectAmbiguous, markWorkflowEffectCancelled, markWorkflowEffectCommitted, markWorkflowEffectDispatched, markWorkflowEffectFailed, markWorkflowEffectFailedBeforeMayExecute, markWorkflowEffectMayExecute } from "./effect-transitions.js";
|
|
5
5
|
import { claimPreparedWorkflowEffect, markWorkflowEffectSatisfiedWithoutDispatch, prepareWorkflowEffect } from "./effects.js";
|
|
6
|
-
import { listWorkflowEvents } from "./events.js";
|
|
6
|
+
import { listWorkflowEvents, recordWorkflowObservation } from "./events.js";
|
|
7
7
|
import { getIdempotentMutation, idempotentMutation } from "./idempotency.js";
|
|
8
8
|
import { createWorkflowJobAttempt, recordLateWorkflowChildResult, updateWorkflowJobAttempt } from "./job-attempts.js";
|
|
9
9
|
import { activateWorkflowJob, claimNextWorkflowJob, createWorkflowJob, reconcileAbandonedWorkflowJobClaim, updateWorkflowJob } from "./jobs.js";
|
|
@@ -66,6 +66,7 @@ export class OrchestrationDb {
|
|
|
66
66
|
markWorkflowEffectMayExecute = (...args) => markWorkflowEffectMayExecute(this.connection, ...args);
|
|
67
67
|
markWorkflowEffectCommitted = (...args) => markWorkflowEffectCommitted(this.connection, ...args);
|
|
68
68
|
markWorkflowEffectFailed = (...args) => markWorkflowEffectFailed(this.connection, ...args);
|
|
69
|
+
markWorkflowConfigurationRejected = (...args) => markWorkflowConfigurationRejected(this.connection, ...args);
|
|
69
70
|
markWorkflowEffectFailedBeforeMayExecute = (...args) => markWorkflowEffectFailedBeforeMayExecute(this.connection, ...args);
|
|
70
71
|
markWorkflowEffectAmbiguous = (...args) => markWorkflowEffectAmbiguous(this.connection, ...args);
|
|
71
72
|
markWorkflowEffectReceiptLost = (...args) => markWorkflowEffectReceiptLost(this.connection, ...args);
|
|
@@ -80,6 +81,7 @@ export class OrchestrationDb {
|
|
|
80
81
|
consumeWorkflowCapability = (...args) => consumeWorkflowCapability(this.connection, ...args);
|
|
81
82
|
revokeWorkflowCapabilities = (...args) => revokeWorkflowCapabilities(this.connection, ...args);
|
|
82
83
|
listWorkflowEvents = (...args) => listWorkflowEvents(this.connection, ...args);
|
|
84
|
+
recordWorkflowObservation = (...args) => recordWorkflowObservation(this.connection, ...args);
|
|
83
85
|
registerRelayInstance = (...args) => registerRelayInstance(this.connection, ...args);
|
|
84
86
|
heartbeatRelayInstance = (...args) => heartbeatRelayInstance(this.connection, ...args);
|
|
85
87
|
listRelayInstances = (...args) => listRelayInstances(this.connection, ...args);
|
|
@@ -95,6 +95,23 @@ export function markWorkflowEffectFailedBeforeMayExecute(context, input) {
|
|
|
95
95
|
eventType: 'workflow_effect_failed_before_may_execute'
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
|
+
/** A finished configuration write with a known, mismatching readback is safe to retry. */
|
|
99
|
+
export function markWorkflowConfigurationRejected(context, input) {
|
|
100
|
+
return context.immediate(() => {
|
|
101
|
+
const effect = requireEffectByAction(context, input.runId, input.actionId);
|
|
102
|
+
if (effect.kind !== 'configure_root' && effect.kind !== 'configure_child') {
|
|
103
|
+
throw new WorkflowTransitionError('only a configuration effect can have a verified rejection');
|
|
104
|
+
}
|
|
105
|
+
return transitionEffect(context, {
|
|
106
|
+
...input,
|
|
107
|
+
from: ['dispatched'],
|
|
108
|
+
to: 'failed',
|
|
109
|
+
requireMayExecute: true,
|
|
110
|
+
eventKey: input.eventKey ?? `configuration_rejected:${input.actionId}:${input.attemptNumber}`,
|
|
111
|
+
eventType: 'workflow_configuration_rejected'
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
98
115
|
export function markWorkflowEffectAmbiguous(context, input) {
|
|
99
116
|
return transitionEffect(context, {
|
|
100
117
|
...input,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { and, asc, eq } from 'drizzle-orm';
|
|
2
2
|
import { decodeEvent } from "./codecs.js";
|
|
3
|
+
import { idempotentMutation } from "./idempotency.js";
|
|
4
|
+
import { requireRun } from "./records.js";
|
|
3
5
|
import { workflowEvents } from "./schema.js";
|
|
4
6
|
import { nonEmpty, optionalJson } from "./values.js";
|
|
5
7
|
export function listWorkflowEvents(context, runId) {
|
|
@@ -27,3 +29,11 @@ export function appendEvent(context, runId, eventKey, type, data) {
|
|
|
27
29
|
export function touchRun(context, runId, at) {
|
|
28
30
|
context.db.prepare('UPDATE workflow_runs SET updated_at = ? WHERE id = ?').run(at, runId);
|
|
29
31
|
}
|
|
32
|
+
/** Audit read-only probes and notification attempts without changing run/phase state. */
|
|
33
|
+
export function recordWorkflowObservation(context, input) {
|
|
34
|
+
idempotentMutation(context, 'workflow_observation', `${input.runId}:${input.eventKey}`, input, () => {
|
|
35
|
+
requireRun(context, input.runId);
|
|
36
|
+
appendEvent(context, input.runId, input.eventKey, input.type, input.data);
|
|
37
|
+
return { runId: input.runId };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { scrubWorkflowSecrets, withoutWindowEvidence } from "../../shared.js";
|
|
2
|
+
import { withUiPriority } from "../../writes/ui-lock.js";
|
|
3
|
+
import { errorMessage } from "./helpers.js";
|
|
4
|
+
export function workflowBlockMessage(notice, workspace) {
|
|
5
|
+
return withoutWindowEvidence(scrubWorkflowSecrets([
|
|
6
|
+
`Workflow paused in ${workspace} during ${notice.run.blocked?.resumePhase.replaceAll('_', ' ')}: ${notice.action}.`,
|
|
7
|
+
notice.run.blocked?.message,
|
|
8
|
+
`On your phone: ${notice.recovery}.`
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join('\n\n')));
|
|
12
|
+
}
|
|
13
|
+
function blockedAction(context, run) {
|
|
14
|
+
const actionId = run.blocked?.actionId ?? '';
|
|
15
|
+
const kind = context.db.getWorkflowEffect(run.id, actionId)?.kind;
|
|
16
|
+
if (kind)
|
|
17
|
+
return ({
|
|
18
|
+
create_workspace: 'create the workspace',
|
|
19
|
+
configure_root: 'apply the planning settings',
|
|
20
|
+
send_root: 'send the objective to the planner',
|
|
21
|
+
open_child: 'open a helper chat',
|
|
22
|
+
configure_child: 'apply the helper settings',
|
|
23
|
+
send_task: 'send the helper assignment',
|
|
24
|
+
return_baton: 'return the helper result',
|
|
25
|
+
authorize_phase: 'deliver the next phase authorization'
|
|
26
|
+
}[kind] ?? 'advance the Workflow');
|
|
27
|
+
if (actionId.startsWith('compatibility:'))
|
|
28
|
+
return 'check relay compatibility';
|
|
29
|
+
if (actionId.startsWith('job:'))
|
|
30
|
+
return 'read the helper result';
|
|
31
|
+
if (actionId === 'bind-root')
|
|
32
|
+
return 'identify the planning chat';
|
|
33
|
+
return 'advance the Workflow';
|
|
34
|
+
}
|
|
35
|
+
/** Notifications are best effort, with a durable claim before each external send. */
|
|
36
|
+
export async function notifyBlockedRun(context, run) {
|
|
37
|
+
if (run.phase !== 'blocked' || !run.blocked)
|
|
38
|
+
return;
|
|
39
|
+
const event = context.db.listWorkflowEvents(run.id).findLast(event => event.type === 'workflow_blocked');
|
|
40
|
+
if (!event)
|
|
41
|
+
return;
|
|
42
|
+
const eventId = event.id;
|
|
43
|
+
const notice = {
|
|
44
|
+
run,
|
|
45
|
+
eventId,
|
|
46
|
+
action: blockedAction(context, run),
|
|
47
|
+
recovery: run.blocked.retryClass === 'deterministic'
|
|
48
|
+
? 'Retry saved action or Cancel'
|
|
49
|
+
: run.blocked.retryClass === 'ambiguous'
|
|
50
|
+
? 'Review risky replay or Cancel'
|
|
51
|
+
: 'Cancel this run'
|
|
52
|
+
};
|
|
53
|
+
async function attempt(channel, send) {
|
|
54
|
+
const eventKey = `block-${channel}:${eventId}`;
|
|
55
|
+
const claimed = context.db.idempotentMutation('workflow_block_notice', `${run.id}:${eventKey}`, { eventId, channel }, () => {
|
|
56
|
+
context.db.recordWorkflowObservation({
|
|
57
|
+
runId: run.id,
|
|
58
|
+
eventKey,
|
|
59
|
+
type: 'workflow_block_notice_claimed',
|
|
60
|
+
data: { channel, eventId }
|
|
61
|
+
});
|
|
62
|
+
return { runId: run.id };
|
|
63
|
+
});
|
|
64
|
+
if (claimed.replayed)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
await send();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
context.db.recordWorkflowObservation({
|
|
71
|
+
runId: run.id,
|
|
72
|
+
eventKey: `${eventKey}:failed`,
|
|
73
|
+
type: 'workflow_block_notice_failed',
|
|
74
|
+
data: { channel, message: errorMessage(error) }
|
|
75
|
+
});
|
|
76
|
+
console.warn(`[workflow ${run.id}] ${channel} block notice failed: ${errorMessage(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (context.deps.notifyBlocked)
|
|
80
|
+
await attempt('push', () => context.deps.notifyBlocked(notice));
|
|
81
|
+
// A notice would make an unprompted root non-pristine and prevent its retry.
|
|
82
|
+
const canSendRoot = run.rootSessionId &&
|
|
83
|
+
!['creating_workspace', 'binding_root', 'pending_root'].includes(run.blocked.resumePhase) &&
|
|
84
|
+
run.blocked.errorCode !== 'workflow_root_not_pristine' &&
|
|
85
|
+
run.blocked.errorCode !== 'workflow_not_found';
|
|
86
|
+
if (!canSendRoot || !context.deps.sendBlockedNotice)
|
|
87
|
+
return;
|
|
88
|
+
await attempt('root', async () => {
|
|
89
|
+
if (context.db.getUiQuarantine().active)
|
|
90
|
+
return;
|
|
91
|
+
await withUiPriority('background', () => context.deps.sendBlockedNotice(notice));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { errorMessage } from "./helpers.js";
|
|
3
|
+
import { blockRun } from "./state.js";
|
|
4
|
+
const MAX_COMPATIBILITY_READ_FAILURES = 3;
|
|
5
|
+
function failuresSinceRecovery(context, runId) {
|
|
6
|
+
let count = 0;
|
|
7
|
+
for (const event of context.db.listWorkflowEvents(runId)) {
|
|
8
|
+
if (event.type === 'workflow_compatibility_read_failed')
|
|
9
|
+
count++;
|
|
10
|
+
if (event.type === 'workflow_compatibility_read_recovered' || event.type === 'workflow_retry_accepted')
|
|
11
|
+
count = 0;
|
|
12
|
+
}
|
|
13
|
+
return count;
|
|
14
|
+
}
|
|
15
|
+
/** One failed wake spends one persisted attempt. Nothing else runs on that tick. */
|
|
16
|
+
export function compatibilityReadFailed(context, run, error) {
|
|
17
|
+
const message = errorMessage(error);
|
|
18
|
+
context.db.recordWorkflowObservation({
|
|
19
|
+
runId: run.id,
|
|
20
|
+
eventKey: `compatibility-read:${randomUUID()}`,
|
|
21
|
+
type: 'workflow_compatibility_read_failed',
|
|
22
|
+
data: { message }
|
|
23
|
+
});
|
|
24
|
+
if (failuresSinceRecovery(context, run.id) >= MAX_COMPATIBILITY_READ_FAILURES) {
|
|
25
|
+
blockRun(context, run, {
|
|
26
|
+
actionId: `compatibility:${run.id}`,
|
|
27
|
+
errorCode: 'workflow_incompatible_relay',
|
|
28
|
+
message: `The relay process check failed ${MAX_COMPATIBILITY_READ_FAILURES} consecutive times. ${message}`,
|
|
29
|
+
retryClass: 'deterministic'
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Clear the streak only after an entire wake succeeds, including checks at the UI gate. */
|
|
34
|
+
export function compatibilityReadRecovered(context, runId) {
|
|
35
|
+
if (!failuresSinceRecovery(context, runId))
|
|
36
|
+
return;
|
|
37
|
+
context.db.recordWorkflowObservation({
|
|
38
|
+
runId,
|
|
39
|
+
eventKey: `compatibility-recovered:${randomUUID()}`,
|
|
40
|
+
type: 'workflow_compatibility_read_recovered'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -3,7 +3,7 @@ import { workflowCapabilityToken } from "../../shared.js";
|
|
|
3
3
|
import { withUiDispatchHook, withUiPriority } from "../../writes/ui-lock.js";
|
|
4
4
|
import { hashCapabilityToken, WorkflowTransitionError } from "../persistence/db.js";
|
|
5
5
|
import { quarantineAndBlock } from "./effect-recovery.js";
|
|
6
|
-
import { WorkflowCoordinatorError } from "./errors.js";
|
|
6
|
+
import { WorkflowCompatibilityReadError, WorkflowCoordinatorError, WorkflowRoleVerificationError } from "./errors.js";
|
|
7
7
|
import { cleanUnknown, errorCode, errorMessage, preExecutionRetryClass, sameRelay } from "./helpers.js";
|
|
8
8
|
import { isTerminalWorkflowPhase } from "./machine.js";
|
|
9
9
|
import { assertCompatible, blockRun, effectReadCall, requireEffect, requireRun } from "./state.js";
|
|
@@ -176,6 +176,51 @@ export async function reconcileClaimedEffect(context, run, effect, job) {
|
|
|
176
176
|
export async function failEffect(context, run, actionId, attemptNumber, error, job) {
|
|
177
177
|
let effect = requireEffect(context, run.id, actionId);
|
|
178
178
|
const currentRun = requireRun(context, run.id);
|
|
179
|
+
if (error instanceof WorkflowCompatibilityReadError &&
|
|
180
|
+
!isTerminalWorkflowPhase(currentRun.phase) &&
|
|
181
|
+
!effect.mayExecute) {
|
|
182
|
+
// The read failed before any external command was released. Settle this
|
|
183
|
+
// attempt, put the exact intent back, and let the wake budget decide when to pause.
|
|
184
|
+
const failure = {
|
|
185
|
+
runId: run.id,
|
|
186
|
+
actionId,
|
|
187
|
+
owner: context.relay,
|
|
188
|
+
attemptNumber,
|
|
189
|
+
errorCode: error.code,
|
|
190
|
+
errorMessage: error.message
|
|
191
|
+
};
|
|
192
|
+
if (effect.state === 'prepared' && sameRelay(effect.owner, context.relay)) {
|
|
193
|
+
context.db.markWorkflowEffectFailed(failure);
|
|
194
|
+
}
|
|
195
|
+
else if (effect.state === 'dispatched') {
|
|
196
|
+
context.db.markWorkflowEffectFailedBeforeMayExecute(failure);
|
|
197
|
+
}
|
|
198
|
+
if (requireEffect(context, run.id, actionId).state === 'failed') {
|
|
199
|
+
context.db.retryWorkflowEffect(run.id, actionId, `compatibility-retry:${actionId}:${attemptNumber}`);
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
if (error instanceof WorkflowRoleVerificationError &&
|
|
204
|
+
effect.state === 'dispatched' &&
|
|
205
|
+
effect.mayExecute &&
|
|
206
|
+
(effect.kind === 'configure_root' || effect.kind === 'configure_child')) {
|
|
207
|
+
effect = context.db.markWorkflowConfigurationRejected({
|
|
208
|
+
runId: run.id,
|
|
209
|
+
actionId,
|
|
210
|
+
owner: context.relay,
|
|
211
|
+
attemptNumber,
|
|
212
|
+
errorCode: error.code,
|
|
213
|
+
errorMessage: error.message,
|
|
214
|
+
evidence: { applied: true, matched: false }
|
|
215
|
+
});
|
|
216
|
+
blockRun(context, currentRun, {
|
|
217
|
+
actionId,
|
|
218
|
+
errorCode: error.code,
|
|
219
|
+
message: error.message,
|
|
220
|
+
retryClass: 'deterministic'
|
|
221
|
+
});
|
|
222
|
+
return { effect, changed: true };
|
|
223
|
+
}
|
|
179
224
|
if (currentRun.phase === 'cancelled' || currentRun.phase === 'completed') {
|
|
180
225
|
if (effect.state === 'dispatched') {
|
|
181
226
|
if (!effect.mayExecute) {
|
|
@@ -11,3 +11,17 @@ export class WorkflowCoordinatorError extends Error {
|
|
|
11
11
|
this.retryable = options.retryable ?? false;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
/** Only raised after the role write has finished successfully and a present chat was read back. */
|
|
15
|
+
export class WorkflowRoleVerificationError extends WorkflowCoordinatorError {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('workflow_role_verification_failed', 'Conductor no longer matches every frozen role setting; no fallback was selected.', { retryable: true });
|
|
18
|
+
this.name = 'WorkflowRoleVerificationError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The process inventory could not be read. No incompatible relay was observed. */
|
|
22
|
+
export class WorkflowCompatibilityReadError extends WorkflowCoordinatorError {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super('workflow_incompatible_relay', message, { retryable: true });
|
|
25
|
+
this.name = 'WorkflowCompatibilityReadError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -31,6 +31,8 @@ export function sanitizeChildOutcome(outcome) {
|
|
|
31
31
|
? {
|
|
32
32
|
kind: 'success',
|
|
33
33
|
baton: scrubWorkflowSecrets(outcome.baton),
|
|
34
|
+
...(outcome.text === undefined ? {} : { text: scrubWorkflowSecrets(outcome.text) }),
|
|
35
|
+
...(outcome.assistantRowid === undefined ? {} : { assistantRowid: outcome.assistantRowid }),
|
|
34
36
|
...(outcome.evidence === undefined ? {} : { evidence: cleanUnknown(outcome.evidence) })
|
|
35
37
|
}
|
|
36
38
|
: {
|
|
@@ -4,6 +4,7 @@ import { markReceiptLost } from "./effect-recovery.js";
|
|
|
4
4
|
import { runDurableEffect } from "./effects.js";
|
|
5
5
|
import { assertDeliveryReceipt, effectGrant, guardJobs, isDeliveryReceipt, messageReceipt, privateCorrelationBlock, sanitizeChildOutcome, workflowEffectCorrelationMarker } from "./helpers.js";
|
|
6
6
|
import { phaseAfterDeliveredBaton } from "./machine.js";
|
|
7
|
+
import { workflowReportReturnText } from "./report.js";
|
|
7
8
|
import { blockRun, effectCall, effectReadCall, requireEffect, requireRun } from "./state.js";
|
|
8
9
|
export async function driveJobOutcome(context, run, job) {
|
|
9
10
|
const outcome = await context.deps.readChildOutcome({ run, job });
|
|
@@ -109,6 +110,11 @@ export async function driveJobBaton(context, run, job) {
|
|
|
109
110
|
// durable root message; accepted outbox rows do not satisfy the barrier.
|
|
110
111
|
if (designatedFinal && !grant)
|
|
111
112
|
return false;
|
|
113
|
+
// Old outcomes and already-prepared returns keep their original bytes. A new
|
|
114
|
+
// return freezes its public message before any UI attempt or receipt lookup.
|
|
115
|
+
const returnText = outcome.text === undefined
|
|
116
|
+
? outcome.baton
|
|
117
|
+
: workflowReportReturnText(job, outcome, await context.deps.materializeReport({ run, job, outcome }));
|
|
112
118
|
effect = context.db.prepareWorkflowEffect({
|
|
113
119
|
id: `${run.id}:${actionId}`,
|
|
114
120
|
runId: run.id,
|
|
@@ -117,6 +123,7 @@ export async function driveJobBaton(context, run, job) {
|
|
|
117
123
|
jobId: job.id,
|
|
118
124
|
target: { sessionId: run.rootSessionId },
|
|
119
125
|
inputs: {
|
|
126
|
+
returnText,
|
|
120
127
|
...(grant ? { grant } : {}),
|
|
121
128
|
correlationMarker: workflowEffectCorrelationMarker(run.id, actionId)
|
|
122
129
|
},
|
|
@@ -126,6 +133,7 @@ export async function driveJobBaton(context, run, job) {
|
|
|
126
133
|
}).effect;
|
|
127
134
|
}
|
|
128
135
|
const grant = effectGrant(effect) ?? undefined;
|
|
136
|
+
const savedText = effect.inputs?.returnText ?? outcome.baton;
|
|
129
137
|
let current = effect;
|
|
130
138
|
let changed = false;
|
|
131
139
|
if (effect.state !== 'committed') {
|
|
@@ -148,7 +156,7 @@ export async function driveJobBaton(context, run, job) {
|
|
|
148
156
|
...effectCall(run, effect, dispatch, job),
|
|
149
157
|
job,
|
|
150
158
|
sessionId: run.rootSessionId,
|
|
151
|
-
text: [
|
|
159
|
+
text: [savedText, envelope, privateCorrelationBlock(run.id, effect.actionId)].filter(Boolean).join('\n\n')
|
|
152
160
|
});
|
|
153
161
|
},
|
|
154
162
|
validate: assertDeliveryReceipt
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { scrubWorkflowSecrets } from "../../shared.js";
|
|
2
|
+
import { chatCursor } from "../../transcript/cursor.js";
|
|
3
|
+
/** Completion freezes the source. Returning a report never reads the child's later turns. */
|
|
4
|
+
export function workflowReportBody(run, job, outcome) {
|
|
5
|
+
if (!job.childSessionId || outcome.text === undefined)
|
|
6
|
+
throw new Error('Workflow report source is incomplete');
|
|
7
|
+
return scrubWorkflowSecrets([
|
|
8
|
+
`# Workflow ${job.role} report`,
|
|
9
|
+
'',
|
|
10
|
+
`Workflow: ${run.id}`,
|
|
11
|
+
`Workflow job: ${job.id} (${job.logicalKey}, attempt ${job.attemptCount})`,
|
|
12
|
+
`Child chat: ${job.childSessionId}`,
|
|
13
|
+
...(outcome.assistantRowid === undefined ? [] : [`Completion cursor: ${chatCursor(outcome.assistantRowid)}`]),
|
|
14
|
+
'',
|
|
15
|
+
outcome.text
|
|
16
|
+
].join('\n'));
|
|
17
|
+
}
|
|
18
|
+
export function workflowReportReturnText(job, outcome, token) {
|
|
19
|
+
if (!job.childSessionId)
|
|
20
|
+
throw new Error('Workflow report has no child chat');
|
|
21
|
+
const read = {
|
|
22
|
+
session_id: job.childSessionId,
|
|
23
|
+
...(outcome.assistantRowid === undefined ? {} : { near: chatCursor(outcome.assistantRowid), before: 6, after: 0 })
|
|
24
|
+
};
|
|
25
|
+
return scrubWorkflowSecrets([
|
|
26
|
+
outcome.baton,
|
|
27
|
+
`Workflow ${job.role} task ${job.logicalKey} completed.`,
|
|
28
|
+
`Report: ${token}`,
|
|
29
|
+
`For earlier investigation, use read_chat(${JSON.stringify(read)}).`
|
|
30
|
+
].join('\n\n'));
|
|
31
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { WorkflowTransitionError } from "../persistence/db.js";
|
|
2
|
+
import { notifyBlockedRun } from "./blocked.js";
|
|
3
|
+
import { compatibilityReadFailed, compatibilityReadRecovered } from "./compatibility.js";
|
|
2
4
|
import { markReceiptLost, reconcileBlockedEffect } from "./effect-recovery.js";
|
|
5
|
+
import { WorkflowCompatibilityReadError } from "./errors.js";
|
|
3
6
|
import { cleanUnknown, effectSessionId, errorMessage, isDeliveryReceipt, receiptEventSuffix, sanitizeChildOutcome } from "./helpers.js";
|
|
4
7
|
import { driveNextJob } from "./job-dispatch.js";
|
|
5
8
|
import { advanceDeliveredBarrier } from "./job-results.js";
|
|
@@ -16,6 +19,8 @@ export function wake(context, runId) {
|
|
|
16
19
|
}
|
|
17
20
|
export async function wakeInternal(context, runId) {
|
|
18
21
|
heartbeat(context);
|
|
22
|
+
let compatibilityFailed = false;
|
|
23
|
+
let compatibilityChecked = false;
|
|
19
24
|
for (let step = 0; step < MAX_WAKE_STEPS; step++) {
|
|
20
25
|
const run = requireRun(context, runId);
|
|
21
26
|
if (run.phase === 'cancelled') {
|
|
@@ -25,14 +30,21 @@ export async function wakeInternal(context, runId) {
|
|
|
25
30
|
if (run.phase === 'completed')
|
|
26
31
|
break;
|
|
27
32
|
if (run.phase === 'blocked') {
|
|
33
|
+
await notifyBlockedRun(context, run);
|
|
28
34
|
if (await reconcileBlockedEffect(context, run))
|
|
29
35
|
continue;
|
|
30
36
|
break;
|
|
31
37
|
}
|
|
32
38
|
try {
|
|
33
39
|
await assertCompatible(context);
|
|
40
|
+
compatibilityChecked = true;
|
|
34
41
|
}
|
|
35
42
|
catch (error) {
|
|
43
|
+
compatibilityFailed = true;
|
|
44
|
+
if (error instanceof WorkflowCompatibilityReadError) {
|
|
45
|
+
compatibilityReadFailed(context, requireRun(context, runId), error);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
36
48
|
blockRun(context, run, {
|
|
37
49
|
actionId: `compatibility:${run.id}`,
|
|
38
50
|
errorCode: 'workflow_incompatible_relay',
|
|
@@ -46,6 +58,11 @@ export async function wakeInternal(context, runId) {
|
|
|
46
58
|
changed = await driveOnce(context, run);
|
|
47
59
|
}
|
|
48
60
|
catch (error) {
|
|
61
|
+
if (error instanceof WorkflowCompatibilityReadError) {
|
|
62
|
+
compatibilityFailed = true;
|
|
63
|
+
compatibilityReadFailed(context, requireRun(context, runId), error);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
49
66
|
if (error instanceof WorkflowTransitionError) {
|
|
50
67
|
const current = requireRun(context, runId);
|
|
51
68
|
if (current.phase !== run.phase || current.cancellationGeneration !== run.cancellationGeneration)
|
|
@@ -56,6 +73,9 @@ export async function wakeInternal(context, runId) {
|
|
|
56
73
|
if (!changed)
|
|
57
74
|
break;
|
|
58
75
|
}
|
|
76
|
+
if (compatibilityChecked && !compatibilityFailed)
|
|
77
|
+
compatibilityReadRecovered(context, runId);
|
|
78
|
+
await notifyBlockedRun(context, requireRun(context, runId));
|
|
59
79
|
return projection(context, runId);
|
|
60
80
|
}
|
|
61
81
|
export async function observeCancelledRun(context, run) {
|
package/dist-node/src/prefs.js
CHANGED
|
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { stateDir } from "./config.js";
|
|
12
12
|
import { attachmentName, attachmentToken } from "./files/attachments.js";
|
|
13
|
+
import { isAgentEffort } from "./shared.js";
|
|
13
14
|
const MAX_KEYS = 50_000;
|
|
14
15
|
const MAX_KEY_LENGTH = 256;
|
|
15
16
|
const MAX_MARK_LENGTH = 128;
|
|
@@ -33,7 +34,7 @@ function sanitizeAgent(raw) {
|
|
|
33
34
|
const agent = {};
|
|
34
35
|
if (typeof value.model === 'string' && value.model.length <= MAX_AGENT_LABEL_LENGTH)
|
|
35
36
|
agent.model = value.model;
|
|
36
|
-
if (
|
|
37
|
+
if (isAgentEffort(value.effort))
|
|
37
38
|
agent.effort = value.effort;
|
|
38
39
|
if (typeof value.plan === 'boolean')
|
|
39
40
|
agent.plan = value.plan;
|
package/dist-node/src/shared.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* Each of these was a second copy before, and each copy was a way for two screens to
|
|
14
14
|
* disagree about the same workspace.
|
|
15
15
|
*/
|
|
16
|
+
/** Stable wire values used by request validation and the phone's effort controls. */
|
|
17
|
+
export const AGENT_EFFORTS = ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
|
|
18
|
+
export function isAgentEffort(value) {
|
|
19
|
+
return AGENT_EFFORTS.some(effort => effort === value);
|
|
20
|
+
}
|
|
16
21
|
/**
|
|
17
22
|
* Parse the duration grammar accepted by `conductor-remote nosleep`: `90m`,
|
|
18
23
|
* `2h`, `30s`, or bare seconds. Kept here so the CLI and phone cannot drift.
|
package/dist-node/src/wire.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* Before this, the phone kept a hand-written mirror of all of it and `mcp-tools.ts`
|
|
13
13
|
* kept a third copy inline. Nothing enforced the copies — a field renamed in
|
|
14
14
|
* `reads.ts` typechecked cleanly on both sides and surfaced as `undefined` on a
|
|
15
|
-
* phone.
|
|
16
|
-
*
|
|
15
|
+
* phone. Each shape has one owner and is exported here; validated request types
|
|
16
|
+
* are inferred from the Zod schemas in `src/contracts/agent-inputs.ts`.
|
|
17
17
|
*
|
|
18
18
|
* The web app may only `import type` from `src/` (see `scripts/check-imports.ts`).
|
|
19
19
|
* `verbatimModuleSyntax` erases those imports, so nothing here reaches the bundle —
|