agents-relay 2.0.56 → 2.0.58
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/README.md +5 -3
- package/agents/chatgpt-worker.agent.md +4 -6
- package/dist/adapters.js +16 -68
- package/dist/reconciler.js +8 -0
- package/dist/workflow/orchestrator-rescue.js +38 -0
- package/package.json +1 -1
- package/skills/agents-relay/contracts/orchestrator-workflows.md +2 -0
- package/workflow/orchestrator-rescue.mmd +13 -0
package/README.md
CHANGED
|
@@ -230,9 +230,9 @@ Managed tasks are descriptive work for agents, not shell commands. Use `codex` o
|
|
|
230
230
|
|
|
231
231
|
Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay bootstraps workers with the managed PR URL, durable task-comment URL, job/task identity, project, output contract, and mandatory terminal-event contract; workers retrieve the task intent from GitHub rather than receiving a duplicate copy of the stored task prompt.
|
|
232
232
|
|
|
233
|
-
For Codex and ChatGPT, **correlated worker events are authoritative lifecycle inputs**, not a separate durable store. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay subscribes, validates correlation, applies graph transitions, persists results in RelayStore, and projects them to GitHub. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The
|
|
233
|
+
For Codex and ChatGPT, **correlated worker events are authoritative lifecycle inputs**, not a separate durable store. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay subscribes, validates correlation, applies graph transitions, persists results in RelayStore, and projects them to GitHub. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its one-shot submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
|
|
234
234
|
|
|
235
|
-
The ChatGPT adapter uses Relay's `agents/chatgpt-worker.agent.md` lifecycle contract and Browser Workspace `
|
|
235
|
+
The ChatGPT adapter uses Relay's `agents/chatgpt-worker.agent.md` lifecycle contract and invokes Browser Workspace exactly as a one-shot platform action: `browser-workspace platform run chatgpt temporary-submit --auto-session --config <file>`. Browser Workspace owns creation/release of the fresh Temporary Chat session, selectors, attachments, and submit verification; Relay does not self-heal browser failures and instead lets delivery failures enter its normal retry/recovery lifecycle. Legacy `--chatgpt-tab-close-policy` values remain accepted for compatibility around Relay's event barriers, but they do not control the Browser Workspace auto-session lifetime. The adapter never uses a pre-existing user tab, polls for the assistant response, resumes/reopens a thread, or treats conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
|
|
236
236
|
|
|
237
237
|
~~~sh
|
|
238
238
|
npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
|
|
@@ -247,6 +247,8 @@ GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles
|
|
|
247
247
|
|
|
248
248
|
`adapter: orchestrator` is reserved for synchronous self-work by the exact orchestrator turn that creates the Task. Relay deliberately does not auto-start these Tasks during reconciliation. The creator must perform the work before ending the turn and terminalize the same Task with `npx agents-relay task complete --task-id <id> --summary <summary> ...`. If work should continue asynchronously or in a later turn, use a real worker adapter instead.
|
|
249
249
|
|
|
250
|
+
As a narrow recovery exception, reconciliation converts a `QUEUED` work Task that still has `adapter: orchestrator`, no execution identity, and is at least 30 minutes past `createdAt` into a normal ChatGPT-routable Task. This preserves the Task identity, intent, dependencies, output, lineage, priority, and story size while clearing stale routing state. `RUNNING` and terminal Tasks are never converted; the 30-minute rescue does not weaken the synchronous self-task contract for newly created work.
|
|
251
|
+
|
|
250
252
|
For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. For managed jobs, the GitHub PR body is the canonical Job surface: hidden Job metadata marker plus the visible Markdown objective.
|
|
251
253
|
|
|
252
254
|
### Automatic task troubleshooting
|
|
@@ -281,4 +283,4 @@ For autonomous Jobs, `UNBLOCKED` is historical: Relay does not retry that old Ta
|
|
|
281
283
|
|
|
282
284
|
### Browser Workspace product boundary
|
|
283
285
|
|
|
284
|
-
Agents Relay owns the persistent `Relay` browser workspace as product infrastructure. At daemon startup it runs `browser-workspace status`, dynamically imports the returned absolute `admin_helper`, and calls `ensureWorkspace("Relay", 6)`. This bootstrap is best-effort: Browser Workspace discovery/helper failures are logged and skipped so they do not prevent `agents-relayd` from starting; ChatGPT browser work may remain unavailable until Browser Workspace recovers. Ordinary worker
|
|
286
|
+
Agents Relay owns the persistent `Relay` browser workspace as product infrastructure. At daemon startup it runs `browser-workspace status`, dynamically imports the returned absolute `admin_helper`, and calls `ensureWorkspace("Relay", 6)`. This bootstrap is best-effort: Browser Workspace discovery/helper failures are logged and skipped so they do not prevent `agents-relayd` from starting; ChatGPT browser work may remain unavailable until Browser Workspace recovers. Ordinary ChatGPT worker activity uses the public Browser Workspace one-shot `platform run ... --auto-session` command; orchestrator-owned self-healing flows may still use explicit session commands. Relay does not create or delete workspaces through CLI lifecycle commands.
|
|
@@ -11,14 +11,12 @@ You execute one complete Relay task by submitting it to a fresh worker-owned Cha
|
|
|
11
11
|
## Contract
|
|
12
12
|
|
|
13
13
|
- Browser Workspace `platforms/chatgpt` is the sole owner of ChatGPT selectors, readiness checks, attachments, Temporary Chat mechanics, and submit verification.
|
|
14
|
-
- Relay owns worker lifecycle, task/event barriers, routing, cancellation
|
|
15
|
-
- Execute only a one-shot submission
|
|
16
|
-
-
|
|
14
|
+
- Relay owns worker lifecycle, task/event barriers, routing, and cancellation; Browser Workspace owns the one-shot auto-session lifecycle.
|
|
15
|
+
- Execute only a one-shot submission with `platform run chatgpt temporary-submit --auto-session`. Do not expose a create/resume/status/result conversation lifecycle.
|
|
16
|
+
- Let Browser Workspace create and release the fresh Temporary Chat session/tab for the action. Never submit through a pre-existing user tab.
|
|
17
17
|
- Preserve the account's current default model/thinking settings.
|
|
18
18
|
- A submission is accepted only after Browser Workspace returns a verified submit receipt.
|
|
19
19
|
- Treat observed conversation/thread identity as diagnostic only, never as a resumable handle.
|
|
20
|
-
-
|
|
21
|
-
- `after-terminal` releases it after the exact task terminal barrier.
|
|
22
|
-
- `never` leaves the owned session open for test/debug.
|
|
20
|
+
- Legacy ChatGPT tab-close policy values remain accepted by Relay for compatibility, but they do not control Browser Workspace's auto-session lifetime or add self-heal.
|
|
23
21
|
- Do not wait for the assistant response. Durable task success/failure is event-owned by Relay and the executing task.
|
|
24
22
|
- Stop on authentication, MFA, consent, or ambiguous browser state rather than bypassing it.
|
package/dist/adapters.js
CHANGED
|
@@ -151,20 +151,28 @@ function parseDriverJson(output) {
|
|
|
151
151
|
function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
|
|
152
152
|
return override ? { command: override, prefix: [] } : { command: 'npx', prefix: ['browser-workspace'] };
|
|
153
153
|
}
|
|
154
|
-
function browserWorkspaceCommand(cli, args, stdin) {
|
|
154
|
+
function browserWorkspaceCommand(cli, args, stdin, signal) {
|
|
155
155
|
return new Promise((resolve, reject) => {
|
|
156
156
|
const child = spawn(cli.command, [...cli.prefix, ...args], { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
|
|
157
157
|
let stdout = '';
|
|
158
158
|
let stderr = '';
|
|
159
|
+
const abort = () => terminateChild(child);
|
|
159
160
|
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
160
161
|
child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
|
|
161
162
|
child.on('error', reject);
|
|
162
163
|
child.on('close', code => {
|
|
164
|
+
signal?.removeEventListener('abort', abort);
|
|
163
165
|
if (code === 0)
|
|
164
166
|
resolve(stdout.trim());
|
|
167
|
+
else if (signal?.aborted)
|
|
168
|
+
reject(new Error('browser-worker execution aborted'));
|
|
165
169
|
else
|
|
166
170
|
reject(new Error(stderr.trim() || stdout.trim() || `browser-workspace exited ${code}`));
|
|
167
171
|
});
|
|
172
|
+
if (signal?.aborted)
|
|
173
|
+
abort();
|
|
174
|
+
else
|
|
175
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
168
176
|
if (stdin !== undefined) {
|
|
169
177
|
child.stdin?.end(stdin);
|
|
170
178
|
}
|
|
@@ -191,86 +199,26 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
|
|
|
191
199
|
}
|
|
192
200
|
await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
|
|
193
201
|
}
|
|
194
|
-
function
|
|
202
|
+
function autoSessionBrowserWorkerRunner() {
|
|
195
203
|
const cli = browserWorkspaceCli();
|
|
196
204
|
return (_definition, request, _route, signal) => {
|
|
197
|
-
const policy = request.tabClosePolicy ?? 'after-start';
|
|
198
|
-
let activeSessionId;
|
|
199
|
-
let released = false;
|
|
200
|
-
let releaseResolve;
|
|
201
|
-
const releaseBarrier = new Promise(resolve => { releaseResolve = resolve; });
|
|
202
|
-
const release = () => {
|
|
203
|
-
if (released)
|
|
204
|
-
return;
|
|
205
|
-
released = true;
|
|
206
|
-
releaseResolve();
|
|
207
|
-
};
|
|
208
|
-
let submittedResolve;
|
|
209
|
-
let submittedReject;
|
|
210
|
-
const submitted = new Promise((resolve, reject) => {
|
|
211
|
-
submittedResolve = resolve;
|
|
212
|
-
submittedReject = reject;
|
|
213
|
-
});
|
|
214
205
|
const promise = (async () => {
|
|
215
206
|
const configFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-${randomUUID()}.json`);
|
|
216
207
|
try {
|
|
217
208
|
await writeFile(configFile, JSON.stringify({
|
|
218
209
|
prompt: request.prompt,
|
|
219
210
|
file: request.files ?? [],
|
|
220
|
-
close_policy: 'never',
|
|
221
211
|
}));
|
|
222
|
-
const startOutput = await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE]);
|
|
223
|
-
const started = parseWorkspaceJson(startOutput);
|
|
224
|
-
if (typeof started.session_id !== 'string') {
|
|
225
|
-
throw new Error('browser-workspace session start returned no session_id');
|
|
226
|
-
}
|
|
227
|
-
activeSessionId = started.session_id;
|
|
228
212
|
if (signal.aborted)
|
|
229
213
|
throw new Error('browser-worker execution aborted');
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
const wrapper = parseWorkspaceJson(execOutput);
|
|
233
|
-
if (wrapper.ok !== true) {
|
|
234
|
-
throw new Error(typeof wrapper.error === 'string' ? wrapper.error : 'browser-workspace session exec failed');
|
|
235
|
-
}
|
|
236
|
-
const receipt = parseDriverJson(typeof wrapper.stdout === 'string' ? wrapper.stdout : '');
|
|
237
|
-
const result = JSON.stringify(receipt);
|
|
238
|
-
submittedResolve(result);
|
|
239
|
-
if (policy === 'never') {
|
|
240
|
-
await unlink(configFile).catch(() => { });
|
|
241
|
-
activeSessionId = undefined;
|
|
242
|
-
return result;
|
|
243
|
-
}
|
|
244
|
-
await releaseBarrier;
|
|
245
|
-
if (activeSessionId) {
|
|
246
|
-
await browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]);
|
|
247
|
-
activeSessionId = undefined;
|
|
248
|
-
}
|
|
249
|
-
return result;
|
|
214
|
+
const output = await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', 'temporary-submit', '--auto-session', '--config', configFile], undefined, signal);
|
|
215
|
+
return JSON.stringify(parseDriverJson(output));
|
|
250
216
|
}
|
|
251
|
-
|
|
252
|
-
if (activeSessionId) {
|
|
253
|
-
await browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]).catch(() => { });
|
|
254
|
-
activeSessionId = undefined;
|
|
255
|
-
}
|
|
217
|
+
finally {
|
|
256
218
|
await unlink(configFile).catch(() => { });
|
|
257
|
-
const error = failure instanceof Error ? failure : new Error(String(failure));
|
|
258
|
-
submittedReject(error);
|
|
259
|
-
throw error;
|
|
260
219
|
}
|
|
261
220
|
})();
|
|
262
|
-
|
|
263
|
-
release();
|
|
264
|
-
if (activeSessionId) {
|
|
265
|
-
void browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]);
|
|
266
|
-
activeSessionId = undefined;
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
if (signal.aborted)
|
|
270
|
-
cancel();
|
|
271
|
-
else
|
|
272
|
-
signal.addEventListener('abort', cancel, { once: true });
|
|
273
|
-
return { promise, submitted, release, cancel };
|
|
221
|
+
return { promise, submitted: promise, cancel: () => { } };
|
|
274
222
|
};
|
|
275
223
|
}
|
|
276
224
|
function parseBrowserWorkerResponse(output) {
|
|
@@ -326,7 +274,7 @@ export class ChatGptAdapter {
|
|
|
326
274
|
if (controller.signal.aborted)
|
|
327
275
|
throw new Error('browser-worker execution aborted');
|
|
328
276
|
const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
|
|
329
|
-
const run = (this.runner ??
|
|
277
|
+
const run = (this.runner ?? autoSessionBrowserWorkerRunner())(definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
|
|
330
278
|
try {
|
|
331
279
|
const output = await (run.submitted ?? run.promise);
|
|
332
280
|
const response = parseBrowserWorkerResponse(output);
|
|
@@ -335,7 +283,7 @@ export class ChatGptAdapter {
|
|
|
335
283
|
await context.waitForWorkerStart;
|
|
336
284
|
else if (policy === 'after-terminal') {
|
|
337
285
|
if (!context.waitForTerminal)
|
|
338
|
-
throw new Error('ChatGPT after-terminal
|
|
286
|
+
throw new Error('ChatGPT after-terminal compatibility policy requires a terminal event barrier');
|
|
339
287
|
await context.waitForTerminal;
|
|
340
288
|
}
|
|
341
289
|
if (policy !== 'never')
|
package/dist/reconciler.js
CHANGED
|
@@ -19,6 +19,7 @@ import { createReviewChairExecutionPlan } from './review-chair.js';
|
|
|
19
19
|
import { taskTagWorkerPrompt } from './task-tag.js';
|
|
20
20
|
import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
|
|
21
21
|
import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
|
|
22
|
+
import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
|
|
22
23
|
import { launchPlanExecution } from './planner-runtime.js';
|
|
23
24
|
import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
|
|
24
25
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
@@ -154,6 +155,13 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
154
155
|
if (job.state !== 'ACTIVE')
|
|
155
156
|
return job;
|
|
156
157
|
await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
|
|
158
|
+
for (const task of (await this.store.load(jobId)).tasks) {
|
|
159
|
+
const rescue = applyOrchestratorRescue(task, now);
|
|
160
|
+
if (!rescue.rescued)
|
|
161
|
+
continue;
|
|
162
|
+
await this.store.saveTask(task);
|
|
163
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.orchestrator_rescued', 'queued', `Stale orchestrator task ${task.id} converted to ChatGPT execution`, 'orchestrator', { ageMs: rescue.ageMs, adapter: 'chatgpt' }, { agent: 'relay', component: 'agents-relay' }));
|
|
164
|
+
}
|
|
157
165
|
await this.recoverPlannerChildren(job);
|
|
158
166
|
for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
|
|
159
167
|
await this.store.appendTask(recovery);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dispatchWorkflowGraphSync } from './dispatcher.js';
|
|
3
|
+
import { GraphRuntime } from './graph-v2.js';
|
|
4
|
+
export const STALE_ORCHESTRATOR_TASK_MS = 30 * 60 * 1000;
|
|
5
|
+
const source = readFileSync(new URL('../../workflow/orchestrator-rescue.mmd', import.meta.url), 'utf8');
|
|
6
|
+
const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
|
|
7
|
+
export const orchestratorRescueV2 = new GraphRuntime(source, {
|
|
8
|
+
states: new Set(['idle', 'check', 'skip', 'rescued']),
|
|
9
|
+
guards: { rescuable: boolGuard('rescuable') },
|
|
10
|
+
actions: { 'task.rescueOrchestrator': { id: 'task.rescueOrchestrator' } },
|
|
11
|
+
events: { 'orchestrator.rescue.reconcile': { id: 'orchestrator.rescue.reconcile' } },
|
|
12
|
+
});
|
|
13
|
+
export function orchestratorRescueFacts(task, now) {
|
|
14
|
+
const createdAt = Date.parse(task.createdAt);
|
|
15
|
+
const oldEnough = Number.isFinite(createdAt) && now - createdAt >= STALE_ORCHESTRATOR_TASK_MS;
|
|
16
|
+
return { rescuable: task.kind !== 'planner' && task.adapter === 'orchestrator' && task.state === 'QUEUED' && task.executionId === null && oldEnough };
|
|
17
|
+
}
|
|
18
|
+
export function decideOrchestratorRescue(task, now) {
|
|
19
|
+
return dispatchWorkflowGraphSync(orchestratorRescueV2, {
|
|
20
|
+
initialState: 'idle', input: { transport: 'event', id: 'orchestrator.rescue.reconcile', payload: {} },
|
|
21
|
+
durableFacts: orchestratorRescueFacts(task, now), graphName: 'orchestrator-rescue',
|
|
22
|
+
isTransientState: state => state === 'check', maxSteps: 2,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function applyOrchestratorRescue(task, now) {
|
|
26
|
+
const decision = decideOrchestratorRescue(task, now);
|
|
27
|
+
const rescued = decision.accepted && decision.actions.some(action => action.id === 'task.rescueOrchestrator');
|
|
28
|
+
const ageMs = now - Date.parse(task.createdAt);
|
|
29
|
+
if (!rescued)
|
|
30
|
+
return { rescued: false, ageMs };
|
|
31
|
+
task.adapter = 'chatgpt';
|
|
32
|
+
task.workerRequirements = { capabilities: [...task.capabilities], runtime: 'chatgpt' };
|
|
33
|
+
task.workerSelection = undefined;
|
|
34
|
+
task.routing = undefined;
|
|
35
|
+
task.timeoutMs = 0;
|
|
36
|
+
task.updatedAt = new Date(now).toISOString();
|
|
37
|
+
return { rescued: true, ageMs };
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -36,6 +36,8 @@ A **P0 job** (alias: **self job**) is executed directly by the current active or
|
|
|
36
36
|
5. P0/self jobs do not require the normal reviewer gate unless the user explicitly asks for review.
|
|
37
37
|
6. After implementation and local verification, the orchestrator merges the job itself and completes normal post-merge reconciliation.
|
|
38
38
|
|
|
39
|
+
Recovery exception: reconciliation may rescue only a `QUEUED` work Task with `adapter: orchestrator` that has no execution identity and is at least 30 minutes past `createdAt`. It converts that Task in place to a normal ChatGPT-routable Task, preserving its durable intent and Task identity while clearing stale routing state. `RUNNING` and terminal Tasks are not rescued. This is a stale-task safety net, not an asynchronous handoff mechanism for newly created self-work.
|
|
40
|
+
|
|
39
41
|
`job create` still only creates durable job state; the P0 workflow requires the orchestrator to follow it immediately with task creation and execution.
|
|
40
42
|
|
|
41
43
|
## Merge completion vs deployment evidence
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
flowchart LR
|
|
2
|
+
%% relay: initial idle
|
|
3
|
+
idle["state: idle"]
|
|
4
|
+
check["state: check"]
|
|
5
|
+
skip["state: skip"]
|
|
6
|
+
rescued["state: rescued"]
|
|
7
|
+
rescuable["guard: rescuable"]
|
|
8
|
+
rescue["action: task.rescueOrchestrator"]
|
|
9
|
+
idle -->|on: orchestrator.rescue.reconcile| check
|
|
10
|
+
check -->|on: orchestrator.rescue.reconcile| rescuable
|
|
11
|
+
rescuable -->|false| skip
|
|
12
|
+
rescuable -->|true| rescue
|
|
13
|
+
rescue -->|to: rescued| rescued
|