ticlawk 0.1.16-dev.9 → 0.1.17-dev.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/README.md +17 -3
- package/bin/ticlawk.mjs +255 -21
- package/package.json +1 -1
- package/src/adapters/ticlawk/api.mjs +350 -50
- package/src/adapters/ticlawk/credentials.mjs +41 -1
- package/src/adapters/ticlawk/index.mjs +248 -130
- package/src/adapters/ticlawk/wake-client.mjs +1 -1
- package/src/cli/agent-commands.mjs +715 -18
- package/src/core/agent-cli-handlers.mjs +556 -18
- package/src/core/agent-home.mjs +81 -1
- package/src/core/argv.mjs +11 -1
- package/src/core/events/worker-events.mjs +32 -36
- package/src/core/http.mjs +152 -0
- package/src/core/runtime-contract.mjs +0 -1
- package/src/core/runtime-env.mjs +7 -0
- package/src/core/runtime-support.mjs +130 -78
- package/src/runtimes/_shared/agent-handbook.mjs +45 -0
- package/src/runtimes/_shared/brand.mjs +2 -0
- package/src/runtimes/_shared/goal-step-prompt.mjs +98 -0
- package/src/runtimes/_shared/goal-task-protocol.mjs +50 -0
- package/src/runtimes/_shared/handbook/BASICS.md +27 -0
- package/src/runtimes/_shared/handbook/COLLABORATION.md +37 -0
- package/src/runtimes/_shared/handbook/COMMUNICATION.md +55 -0
- package/src/runtimes/_shared/handbook/DM_SCOPE.md +13 -0
- package/src/runtimes/_shared/handbook/GOAL_AUTHORITY.md +47 -0
- package/src/runtimes/_shared/handbook/GOAL_TASK_CORE.md +43 -0
- package/src/runtimes/_shared/handbook/GROUP_ADMIN_SCOPE.md +21 -0
- package/src/runtimes/_shared/handbook/GROUP_MEMBER_SCOPE.md +15 -0
- package/src/runtimes/_shared/handbook/SURFACES.md +41 -0
- package/src/runtimes/_shared/handbook/TASK_WORKER.md +14 -0
- package/src/runtimes/_shared/standing-prompt.mjs +124 -279
- package/src/runtimes/_shared/wake-prompt.mjs +268 -0
- package/src/runtimes/claude-code/index.mjs +19 -46
- package/src/runtimes/claude-code/session.mjs +2 -7
- package/src/runtimes/codex/index.mjs +115 -63
- package/src/runtimes/codex/session.mjs +2 -12
- package/src/runtimes/openclaw/index.mjs +11 -24
- package/src/runtimes/opencode/index.mjs +38 -60
- package/src/runtimes/opencode/session.mjs +12 -12
- package/src/runtimes/pi/index.mjs +38 -60
- package/src/runtimes/pi/session.mjs +9 -6
- package/ticlawk.mjs +0 -30
|
@@ -10,13 +10,16 @@ import { isTerminalRuntimeFailure } from '../../core/runtime-support.mjs';
|
|
|
10
10
|
import { clearUpdateRequiredState, readUpdateState, setUpdateRequiredState } from '../../core/update-state.mjs';
|
|
11
11
|
import { isManagedInstall, startDetachedSelfUpdate } from '../../core/update.mjs';
|
|
12
12
|
import * as api from './api.mjs';
|
|
13
|
-
import { persistApiCredential } from './credentials.mjs';
|
|
13
|
+
import { persistApiCredential, persistRuntimeCredentials } from './credentials.mjs';
|
|
14
14
|
import { TiclawkWakeClient } from './wake-client.mjs';
|
|
15
|
+
import { buildInboundWakePrompt } from '../../runtimes/_shared/wake-prompt.mjs';
|
|
16
|
+
import { buildGoalStepPrompt } from '../../runtimes/_shared/goal-step-prompt.mjs';
|
|
15
17
|
|
|
16
18
|
const require = createRequire(import.meta.url);
|
|
17
19
|
const qrcode = require('qrcode-terminal');
|
|
18
20
|
const JOBS_WAKE_DEBOUNCE_MS = 100;
|
|
19
21
|
const BINDINGS_WAKE_DEBOUNCE_MS = 500;
|
|
22
|
+
const CREDENTIALS_WAKE_DEBOUNCE_MS = 500;
|
|
20
23
|
const RECOVERY_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
|
|
21
24
|
const BINDING_AUDIT_INTERVAL_MS = 5 * 60 * 1000;
|
|
22
25
|
const UPDATE_RETRY_COOLDOWN_MS = 15 * 60 * 1000;
|
|
@@ -69,98 +72,6 @@ function normalizeInboundMediaAssets(msg) {
|
|
|
69
72
|
.filter(Boolean);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
function buildEnvelopeTarget(msg) {
|
|
73
|
-
const convType = msg.conversation_type || 'dm';
|
|
74
|
-
const conversationId = msg.conversation_id || '';
|
|
75
|
-
const senderHandle = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || '';
|
|
76
|
-
if (convType === 'dm') {
|
|
77
|
-
return senderHandle ? `dm:@${senderHandle}` : `dm:${conversationId}`;
|
|
78
|
-
}
|
|
79
|
-
if (convType === 'thread') {
|
|
80
|
-
const groupName = msg.conversation_name || conversationId;
|
|
81
|
-
const threadRoot = msg.thread_root_message_id || msg.message_id || '';
|
|
82
|
-
return `#${groupName}:${threadRoot}`;
|
|
83
|
-
}
|
|
84
|
-
// group
|
|
85
|
-
const groupName = msg.conversation_name || conversationId;
|
|
86
|
-
return `#${groupName}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function buildTaskSuffix(msg) {
|
|
90
|
-
if (msg.task_number == null) return '';
|
|
91
|
-
const status = msg.task_status || 'todo';
|
|
92
|
-
const parts = [`task #${msg.task_number} status=${status}`];
|
|
93
|
-
if (msg.task_assignee_agent_id || msg.task_assignee_user_id) {
|
|
94
|
-
const t = msg.task_assignee_type || 'agent';
|
|
95
|
-
const id = msg.task_assignee_agent_id || msg.task_assignee_user_id;
|
|
96
|
-
parts.push(`assignee=${t}:${id}`);
|
|
97
|
-
}
|
|
98
|
-
if (msg.task_title) {
|
|
99
|
-
parts.push(`title=${JSON.stringify(msg.task_title)}`);
|
|
100
|
-
}
|
|
101
|
-
return ` [${parts.join(' ')}]`;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function buildReactionsSuffix(msg) {
|
|
105
|
-
const entries = Array.isArray(msg.reactions_summary) ? msg.reactions_summary : [];
|
|
106
|
-
if (entries.length === 0) return '';
|
|
107
|
-
return ` [reactions: ${entries.join('; ')}]`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function buildEnvelopeHeader(msg) {
|
|
111
|
-
const target = buildEnvelopeTarget(msg);
|
|
112
|
-
const msgId = msg.id || msg.message_id || '';
|
|
113
|
-
const seq = msg.seq != null ? msg.seq : '';
|
|
114
|
-
const time = msg.created_at || new Date().toISOString();
|
|
115
|
-
const type = msg.sender_type || 'human';
|
|
116
|
-
const sender = msg.sender_display_name || msg.sender_user_id || msg.sender_agent_id || 'unknown';
|
|
117
|
-
// `reason` tells the agent how this delivery was routed: 'mention'
|
|
118
|
-
// / 'assignment' = you were directly addressed → respond by default;
|
|
119
|
-
// 'ambient' = you are in the room and saw it → respond only if
|
|
120
|
-
// clearly the right responder; 'dm' / 'thread_follow' / 'manual' =
|
|
121
|
-
// the legacy direct paths. The agent's behaviour split is in the
|
|
122
|
-
// standing prompt; we just surface the field.
|
|
123
|
-
const reason = msg.reason ? ` reason=${msg.reason}` : '';
|
|
124
|
-
return `[target=${target} msg=${msgId} seq=${seq} time=${time} type=${type}${reason}] @${sender}:`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function buildGroupContextBlock(msg) {
|
|
128
|
-
// Only useful in groups — DMs don't need group-purpose context.
|
|
129
|
-
if ((msg.conversation_type || 'dm') !== 'group') return '';
|
|
130
|
-
const lines = [];
|
|
131
|
-
const name = msg.conversation_name || msg.conversation_display_name || '';
|
|
132
|
-
if (name) lines.push(`name: ${name}`);
|
|
133
|
-
const description = (msg.conversation_description || '').trim();
|
|
134
|
-
if (description) lines.push(`purpose: ${description}`);
|
|
135
|
-
if (lines.length === 0) return '';
|
|
136
|
-
return [
|
|
137
|
-
'Group context:',
|
|
138
|
-
...lines.map((l) => ` ${l}`),
|
|
139
|
-
'Use `ticlawk group members --target <target>` if you need to see who else is here.',
|
|
140
|
-
].join('\n');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Wrap each per-turn message with an explicit reply instruction so the
|
|
144
|
-
// runtime LLM never has to remember the standing prompt to figure out
|
|
145
|
-
// HOW to reply. Codex in particular treats the developerInstructions as
|
|
146
|
-
// background and ignores the chat-send pattern without this per-turn
|
|
147
|
-
// nudge.
|
|
148
|
-
function buildWakePromptText({ envelopeHeader, target, rawText, groupContext }) {
|
|
149
|
-
const body = `${envelopeHeader} ${rawText || ''}`.trim();
|
|
150
|
-
const lines = ['New message received:', '', body];
|
|
151
|
-
if (groupContext) {
|
|
152
|
-
lines.push('', groupContext);
|
|
153
|
-
}
|
|
154
|
-
lines.push(
|
|
155
|
-
'',
|
|
156
|
-
`Respond as appropriate — reply using \`ticlawk message send --target "${target}"\` (body via stdin / heredoc), or take action as needed. Complete ALL your work before stopping.`,
|
|
157
|
-
'Reply in the channel or create/reply in a thread as appropriate; use each message\'s `target` and `msg` fields to choose the exact target.',
|
|
158
|
-
'',
|
|
159
|
-
'IMPORTANT: If the message requires multi-step work (research, code changes, testing), complete ALL steps before stopping. Sending a progress update does NOT mean your task is done — only stop when you have NO more work to do. The daemon will wake you again automatically when new messages arrive.',
|
|
160
|
-
);
|
|
161
|
-
return lines.join('\n');
|
|
162
|
-
}
|
|
163
|
-
|
|
164
75
|
export function normalizeInboundMessage(msg) {
|
|
165
76
|
// Claimed delivery rows carry the recipient agent id; legacy messages
|
|
166
77
|
// (history sync, manual inserts) may still use plain agent_id. Prefer
|
|
@@ -169,33 +80,24 @@ export function normalizeInboundMessage(msg) {
|
|
|
169
80
|
const messageId = msg.id || msg.message_id || null;
|
|
170
81
|
const deliveryId = msg.delivery_id || null;
|
|
171
82
|
const media = normalizeInboundMediaAssets(msg);
|
|
172
|
-
const rawText = msg.text || '';
|
|
173
83
|
const enriched = { ...msg, id: messageId };
|
|
174
|
-
const
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
// `[reactions: …]`.
|
|
179
|
-
const taskSuffix = buildTaskSuffix(enriched);
|
|
180
|
-
const reactionsSuffix = buildReactionsSuffix(enriched);
|
|
181
|
-
const header = baseHeader + taskSuffix + reactionsSuffix;
|
|
182
|
-
const target = buildEnvelopeTarget(enriched);
|
|
183
|
-
const groupContext = buildGroupContextBlock(enriched);
|
|
184
|
-
const text = header
|
|
185
|
-
? buildWakePromptText({ envelopeHeader: header, target, rawText, groupContext })
|
|
186
|
-
: rawText;
|
|
84
|
+
const isTransition = msg.reason === 'transition';
|
|
85
|
+
// A transition delivery is a goal-lane FSM step, not a chat message: it has
|
|
86
|
+
// no backing message, so build a per-step goal prompt from its payload.
|
|
87
|
+
const wakePrompt = isTransition ? buildGoalStepPrompt(enriched) : buildInboundWakePrompt(enriched);
|
|
187
88
|
return {
|
|
188
89
|
bindingId: recipientAgentId,
|
|
189
90
|
messageId,
|
|
190
91
|
deliveryId,
|
|
191
92
|
conversationId: msg.conversation_id || null,
|
|
93
|
+
lane: isTransition ? 'goal' : 'chat',
|
|
192
94
|
seq: msg.seq != null ? Number(msg.seq) : null,
|
|
193
95
|
senderType: msg.sender_type || 'human',
|
|
194
|
-
envelopeHeader: header,
|
|
195
|
-
envelopeTarget: target,
|
|
196
|
-
text,
|
|
197
|
-
rawText,
|
|
198
|
-
action: msg.action || (media.length > 0 ? 'image' : 'task'),
|
|
96
|
+
envelopeHeader: wakePrompt.header,
|
|
97
|
+
envelopeTarget: wakePrompt.target,
|
|
98
|
+
text: wakePrompt.text,
|
|
99
|
+
rawText: wakePrompt.rawText,
|
|
100
|
+
action: isTransition ? 'transition' : (msg.action || (media.length > 0 ? 'image' : 'task')),
|
|
199
101
|
media,
|
|
200
102
|
raw: {
|
|
201
103
|
...msg,
|
|
@@ -233,6 +135,19 @@ function getAgentIdFromPayload(payload) {
|
|
|
233
135
|
return String(payload?.recipient_agent_id || '').trim();
|
|
234
136
|
}
|
|
235
137
|
|
|
138
|
+
// Two lanes run concurrently per agent (see the lane-aware claim RPC):
|
|
139
|
+
// the goal lane carries FSM transition deliveries (reason='transition'),
|
|
140
|
+
// everything else is the user-facing chat lane. The claim unit and the
|
|
141
|
+
// daemon's in-flight key are the (agent, lane) channel, so a running chat
|
|
142
|
+
// turn and a running goal transition never block each other.
|
|
143
|
+
function getDeliveryLaneFromPayload(payload) {
|
|
144
|
+
return payload?.reason === 'transition' ? 'goal' : 'chat';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function channelKeyFor(agentId, lane) {
|
|
148
|
+
return `${agentId}:${lane}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
236
151
|
// Whitelist of meta keys runtimes actually consume. Anything else in
|
|
237
152
|
// the source row's meta blob is dropped on the floor so stale fields
|
|
238
153
|
// (like the post-Y2 workdir/cwd/projectDir leftovers) can't sneak
|
|
@@ -243,6 +158,11 @@ const RUNTIME_META_KEYS = [
|
|
|
243
158
|
'runtimePath',
|
|
244
159
|
'rotatePending',
|
|
245
160
|
'lastRotatedAt',
|
|
161
|
+
// Per-lane scoped session maps (keyed by conversation): the chat lane and
|
|
162
|
+
// the goal-FSM lane keep separate runtime sessions so a transition turn
|
|
163
|
+
// never resumes a user-chat session or vice-versa.
|
|
164
|
+
'chatSessions',
|
|
165
|
+
'goalSessions',
|
|
246
166
|
// claude_code
|
|
247
167
|
'claudePath',
|
|
248
168
|
'claudeVersion',
|
|
@@ -359,6 +279,31 @@ function getRuntimeWorkdir(runtimeMeta = {}) {
|
|
|
359
279
|
return String(runtimeMeta.workdir || runtimeMeta.cwd || runtimeMeta.projectDir || '').trim();
|
|
360
280
|
}
|
|
361
281
|
|
|
282
|
+
function compareStrings(a, b) {
|
|
283
|
+
const av = String(a || '');
|
|
284
|
+
const bv = String(b || '');
|
|
285
|
+
if (av < bv) return -1;
|
|
286
|
+
if (av > bv) return 1;
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function compareNumbers(a, b) {
|
|
291
|
+
const av = Number(a);
|
|
292
|
+
const bv = Number(b);
|
|
293
|
+
const aFinite = Number.isFinite(av);
|
|
294
|
+
const bFinite = Number.isFinite(bv);
|
|
295
|
+
if (aFinite && bFinite && av !== bv) return av - bv;
|
|
296
|
+
if (aFinite !== bFinite) return aFinite ? -1 : 1;
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function compareClaimedDeliveries(a, b) {
|
|
301
|
+
return compareStrings(a?.created_at, b?.created_at)
|
|
302
|
+
|| compareStrings(a?.conversation_id, b?.conversation_id)
|
|
303
|
+
|| compareNumbers(a?.seq, b?.seq)
|
|
304
|
+
|| compareStrings(a?.delivery_id, b?.delivery_id);
|
|
305
|
+
}
|
|
306
|
+
|
|
362
307
|
function runtimeLabel(runtime) {
|
|
363
308
|
if (runtime === 'claude_code') return 'Claude Code';
|
|
364
309
|
if (runtime === 'opencode') return 'OpenCode';
|
|
@@ -472,8 +417,10 @@ export function createTiclawkAdapter(ctx) {
|
|
|
472
417
|
let bindingAuditTimer = null;
|
|
473
418
|
let jobsWakeTimer = null;
|
|
474
419
|
let bindingsWakeTimer = null;
|
|
420
|
+
let credentialsWakeTimer = null;
|
|
475
421
|
let lastJobsWakeAt = 0;
|
|
476
422
|
let lastBindingsWakeAt = 0;
|
|
423
|
+
let lastCredentialsWakeAt = 0;
|
|
477
424
|
let updateRequired = null;
|
|
478
425
|
let lastUpdateRequiredLogAt = 0;
|
|
479
426
|
|
|
@@ -649,7 +596,7 @@ export function createTiclawkAdapter(ctx) {
|
|
|
649
596
|
|
|
650
597
|
const binding = await ctx.persistBinding(buildBindingFromSource(msg));
|
|
651
598
|
if (!binding?.runtime) {
|
|
652
|
-
throw new Error('claimed
|
|
599
|
+
throw new Error('claimed delivery missing runtime binding');
|
|
653
600
|
}
|
|
654
601
|
if (!belongsToRuntimeHost(binding, hostId)) {
|
|
655
602
|
await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
|
|
@@ -717,6 +664,91 @@ export function createTiclawkAdapter(ctx) {
|
|
|
717
664
|
}
|
|
718
665
|
}
|
|
719
666
|
|
|
667
|
+
// The goal lane's canonical completion is `ticlawk goal report`, which
|
|
668
|
+
// completes the transition delivery inside report_goal_transition (before
|
|
669
|
+
// emitting the next step) so the live-transition slot frees up. By the time
|
|
670
|
+
// the turn returns the row is usually already 'completed', so this
|
|
671
|
+
// best-effort complete then 409s — that is the expected healthy path, not an
|
|
672
|
+
// error. A turn that finished without reporting leaves the row 'claimed' for
|
|
673
|
+
// stale-claimed recovery.
|
|
674
|
+
async function completeGoalDelivery(deliveryId) {
|
|
675
|
+
try {
|
|
676
|
+
await api.completeDelivery(deliveryId, hostId);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
if (err?.status === 409) return;
|
|
679
|
+
throw err;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function processGoalTransitionsForAgent(agentId, messages) {
|
|
684
|
+
for (const msg of messages) {
|
|
685
|
+
const deliveryId = msg.delivery_id;
|
|
686
|
+
const step = msg?.payload?.step || null;
|
|
687
|
+
try {
|
|
688
|
+
const messageHostId = getRuntimeHostIdFromPayload(msg);
|
|
689
|
+
if (messageHostId && messageHostId !== hostId) {
|
|
690
|
+
await api.releaseDelivery(deliveryId, hostId, 'host-mismatch');
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const binding = await ctx.persistBinding(buildBindingFromSource(msg));
|
|
695
|
+
if (!binding?.runtime) {
|
|
696
|
+
throw new Error('claimed transition missing runtime binding');
|
|
697
|
+
}
|
|
698
|
+
if (!belongsToRuntimeHost(binding, hostId)) {
|
|
699
|
+
await api.releaseDelivery(deliveryId, hostId, 'binding-host-mismatch');
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const completed = await ctx.bus.dispatchToAgent(binding.runtime, binding.id, normalizeInboundMessage(msg));
|
|
704
|
+
if (completed !== true) {
|
|
705
|
+
if (isTerminalRuntimeFailure(completed)) {
|
|
706
|
+
await completeGoalDelivery(deliveryId);
|
|
707
|
+
void requestDrain('transition.terminal-completed');
|
|
708
|
+
debugError('ticlawk', 'transition.terminal-failed', {
|
|
709
|
+
agentId,
|
|
710
|
+
deliveryId,
|
|
711
|
+
step,
|
|
712
|
+
runtime: binding.runtime,
|
|
713
|
+
reason: completed.reason || 'runtime terminal failure',
|
|
714
|
+
});
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
throw new Error('runtime did not complete transition turn');
|
|
718
|
+
}
|
|
719
|
+
await completeGoalDelivery(deliveryId);
|
|
720
|
+
void requestDrain('transition.completed');
|
|
721
|
+
debugLog('ticlawk', 'transition.completed', {
|
|
722
|
+
agentId,
|
|
723
|
+
deliveryId,
|
|
724
|
+
step,
|
|
725
|
+
runtime: binding.runtime,
|
|
726
|
+
});
|
|
727
|
+
} catch (err) {
|
|
728
|
+
if (api.isUpdateRequiredError(err)) {
|
|
729
|
+
recordUpdateRequired(err, 'transition.dispatch');
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
await api.releaseDelivery(deliveryId, hostId, 'transition-dispatch-error');
|
|
733
|
+
} catch (releaseErr) {
|
|
734
|
+
debugError('ticlawk', 'transition.release-failed', {
|
|
735
|
+
agentId,
|
|
736
|
+
deliveryId,
|
|
737
|
+
hostId,
|
|
738
|
+
error: releaseErr?.message || 'unknown error',
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
debugError('ticlawk', 'transition.dispatch-failed', {
|
|
742
|
+
agentId,
|
|
743
|
+
deliveryId,
|
|
744
|
+
step,
|
|
745
|
+
hostId,
|
|
746
|
+
error: err?.message || 'unknown error',
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
720
752
|
async function refreshBindings(reason = 'manual') {
|
|
721
753
|
let channels = [];
|
|
722
754
|
const startedAt = Date.now();
|
|
@@ -755,6 +787,27 @@ export function createTiclawkAdapter(ctx) {
|
|
|
755
787
|
error: err?.message || 'unknown error',
|
|
756
788
|
});
|
|
757
789
|
}
|
|
790
|
+
|
|
791
|
+
// Agents created from the App via POST /me/agents land here in
|
|
792
|
+
// status='unpaired'. Once we've successfully registered the
|
|
793
|
+
// binding locally, flip them to 'connected' — same end state the
|
|
794
|
+
// legacy QR pairing flow leaves agents in. spawn itself stays
|
|
795
|
+
// lazy (happens on first delivery via deliverTurn).
|
|
796
|
+
if (agent.status === 'unpaired' && agentHostId === hostId) {
|
|
797
|
+
try {
|
|
798
|
+
await api.updateAgent(agent.id, {
|
|
799
|
+
status: 'connected',
|
|
800
|
+
runtime_host_id: hostId,
|
|
801
|
+
runtime_host_label: getHostLabel(),
|
|
802
|
+
});
|
|
803
|
+
debugLog('ticlawk', 'binding.unpaired-claimed', { agentId: agent.id });
|
|
804
|
+
} catch (err) {
|
|
805
|
+
debugError('ticlawk', 'binding.unpaired-claim-failed', {
|
|
806
|
+
agentId: agent.id,
|
|
807
|
+
error: err?.message || 'unknown error',
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
758
811
|
}
|
|
759
812
|
const pruned = await pruneDeletedBindings(channels, reason);
|
|
760
813
|
debugLog('ticlawk', 'binding.refresh-ok', {
|
|
@@ -769,6 +822,33 @@ export function createTiclawkAdapter(ctx) {
|
|
|
769
822
|
return hydrated;
|
|
770
823
|
}
|
|
771
824
|
|
|
825
|
+
const syncCredentials = coalesce(async (reason = 'manual') => {
|
|
826
|
+
const startedAt = Date.now();
|
|
827
|
+
try {
|
|
828
|
+
const payload = await api.fetchCredentials();
|
|
829
|
+
const credentials = Array.isArray(payload?.credentials) ? payload.credentials : [];
|
|
830
|
+
const result = persistRuntimeCredentials(credentials);
|
|
831
|
+
debugLog('ticlawk-credentials', 'sync-ok', {
|
|
832
|
+
reason,
|
|
833
|
+
saved: result.saved,
|
|
834
|
+
removed: result.removed,
|
|
835
|
+
durationMs: Date.now() - startedAt,
|
|
836
|
+
wakeToSyncMs: String(reason || '').startsWith('wake') && lastCredentialsWakeAt
|
|
837
|
+
? Date.now() - lastCredentialsWakeAt
|
|
838
|
+
: null,
|
|
839
|
+
});
|
|
840
|
+
} catch (err) {
|
|
841
|
+
if (api.isUpdateRequiredError(err)) {
|
|
842
|
+
recordUpdateRequired(err, 'credentials.sync');
|
|
843
|
+
}
|
|
844
|
+
debugError('ticlawk-credentials', 'sync-failed', {
|
|
845
|
+
reason,
|
|
846
|
+
durationMs: Date.now() - startedAt,
|
|
847
|
+
error: err?.message || 'unknown error',
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
|
|
772
852
|
async function releaseBlockedRows(agentId, messages, reason) {
|
|
773
853
|
for (const msg of messages) {
|
|
774
854
|
if (!msg?.delivery_id) continue;
|
|
@@ -817,7 +897,8 @@ export function createTiclawkAdapter(ctx) {
|
|
|
817
897
|
return { failed: true, claimed: 0, launched: 0 };
|
|
818
898
|
}
|
|
819
899
|
|
|
820
|
-
const
|
|
900
|
+
const orderedData = Array.isArray(data) ? [...data].sort(compareClaimedDeliveries) : [];
|
|
901
|
+
const claimed = orderedData.length;
|
|
821
902
|
debugLog('ticlawk', 'claim.result', {
|
|
822
903
|
reason,
|
|
823
904
|
hostId,
|
|
@@ -836,12 +917,12 @@ export function createTiclawkAdapter(ctx) {
|
|
|
836
917
|
});
|
|
837
918
|
}
|
|
838
919
|
|
|
839
|
-
if (
|
|
920
|
+
if (orderedData.length === 0) {
|
|
840
921
|
return { failed: false, claimed: 0, launched: 0 };
|
|
841
922
|
}
|
|
842
923
|
|
|
843
924
|
const grouped = new Map();
|
|
844
|
-
for (const msg of
|
|
925
|
+
for (const msg of orderedData) {
|
|
845
926
|
const agentId = getAgentIdFromPayload(msg);
|
|
846
927
|
if (!agentId) {
|
|
847
928
|
// Claim rows must carry the recipient agent id; a missing value
|
|
@@ -854,33 +935,39 @@ export function createTiclawkAdapter(ctx) {
|
|
|
854
935
|
});
|
|
855
936
|
continue;
|
|
856
937
|
}
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
grouped.
|
|
938
|
+
const lane = getDeliveryLaneFromPayload(msg);
|
|
939
|
+
const channelKey = channelKeyFor(agentId, lane);
|
|
940
|
+
const bucket = grouped.get(channelKey) || { agentId, lane, messages: [] };
|
|
941
|
+
bucket.messages.push(msg);
|
|
942
|
+
grouped.set(channelKey, bucket);
|
|
860
943
|
}
|
|
861
944
|
|
|
862
945
|
let launched = 0;
|
|
863
|
-
for (const [agentId, messages] of grouped.entries()) {
|
|
864
|
-
if (processingChannels.has(
|
|
946
|
+
for (const [channelKey, { agentId, lane, messages }] of grouped.entries()) {
|
|
947
|
+
if (processingChannels.has(channelKey)) {
|
|
865
948
|
debugError('ticlawk', 'claim.blocked-claimed-rows', {
|
|
866
949
|
reason,
|
|
950
|
+
channelKey,
|
|
867
951
|
agentId,
|
|
952
|
+
lane,
|
|
868
953
|
blockedRows: messages.length,
|
|
869
954
|
});
|
|
870
955
|
await releaseBlockedRows(agentId, messages, reason);
|
|
871
956
|
continue;
|
|
872
957
|
}
|
|
873
|
-
const run =
|
|
958
|
+
const run = (lane === 'goal'
|
|
959
|
+
? processGoalTransitionsForAgent(agentId, messages)
|
|
960
|
+
: processPendingMessagesForAgent(agentId, messages))
|
|
874
961
|
.catch(() => {})
|
|
875
962
|
.finally(() => {
|
|
876
|
-
processingChannels.delete(
|
|
963
|
+
processingChannels.delete(channelKey);
|
|
877
964
|
void requestDrain('channel.completed');
|
|
878
965
|
});
|
|
879
|
-
processingChannels.set(
|
|
966
|
+
processingChannels.set(channelKey, run);
|
|
880
967
|
launched += messages.length;
|
|
881
968
|
}
|
|
882
969
|
|
|
883
|
-
return { failed: false, claimed:
|
|
970
|
+
return { failed: false, claimed: orderedData.length, launched };
|
|
884
971
|
}
|
|
885
972
|
|
|
886
973
|
async function runDrain(reason) {
|
|
@@ -919,6 +1006,15 @@ export function createTiclawkAdapter(ctx) {
|
|
|
919
1006
|
bindingsWakeTimer.unref?.();
|
|
920
1007
|
}
|
|
921
1008
|
|
|
1009
|
+
function scheduleCredentialSync(reason) {
|
|
1010
|
+
credentialsWakeTimer = clearDebounce(credentialsWakeTimer);
|
|
1011
|
+
credentialsWakeTimer = setTimeout(() => {
|
|
1012
|
+
credentialsWakeTimer = null;
|
|
1013
|
+
void syncCredentials(reason);
|
|
1014
|
+
}, CREDENTIALS_WAKE_DEBOUNCE_MS);
|
|
1015
|
+
credentialsWakeTimer.unref?.();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
922
1018
|
async function reportHostCapabilitiesNow() {
|
|
923
1019
|
const entries = await Promise.all(Object.entries(ctx.runtimes || {})
|
|
924
1020
|
.filter(([, runtime]) => typeof runtime?.health === 'function')
|
|
@@ -958,6 +1054,7 @@ export function createTiclawkAdapter(ctx) {
|
|
|
958
1054
|
void refreshBindings('wake.hello')
|
|
959
1055
|
.then(() => requestDrain('wake.hello'))
|
|
960
1056
|
.catch(() => {});
|
|
1057
|
+
void syncCredentials('wake.hello');
|
|
961
1058
|
void reportHostCapabilitiesNow();
|
|
962
1059
|
return;
|
|
963
1060
|
}
|
|
@@ -979,6 +1076,17 @@ export function createTiclawkAdapter(ctx) {
|
|
|
979
1076
|
scheduleRefreshAndDrain('wake.bindings.changed');
|
|
980
1077
|
return;
|
|
981
1078
|
}
|
|
1079
|
+
if (event?.type === 'credentials.changed') {
|
|
1080
|
+
lastCredentialsWakeAt = Date.now();
|
|
1081
|
+
debugLog('ticlawk-wake', 'credentials.changed', {
|
|
1082
|
+
credentialId: event.credential_id || null,
|
|
1083
|
+
name: event.name || null,
|
|
1084
|
+
status: event.status || null,
|
|
1085
|
+
reason: event.reason || null,
|
|
1086
|
+
});
|
|
1087
|
+
scheduleCredentialSync('wake.credentials.changed');
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
982
1090
|
if (event?.type === 'auth.revoked') {
|
|
983
1091
|
wakeState.lastError = 'auth revoked';
|
|
984
1092
|
debugError('ticlawk-wake', 'auth.revoked', {});
|
|
@@ -1013,7 +1121,17 @@ export function createTiclawkAdapter(ctx) {
|
|
|
1013
1121
|
|
|
1014
1122
|
function connectWakeSocket() {
|
|
1015
1123
|
connectorSocket = new TiclawkWakeClient({
|
|
1016
|
-
|
|
1124
|
+
// host_id rides on the WS query string so connector-wake can flip
|
|
1125
|
+
// runtime_hosts.online for the right row. Empty host_id is
|
|
1126
|
+
// tolerated by the server (it just skips the state write).
|
|
1127
|
+
getUrl: () => {
|
|
1128
|
+
const base = String(api.getConnectorWsUrl() || '').trim();
|
|
1129
|
+
if (!base) return '';
|
|
1130
|
+
const hostId = String(getHostId() || '').trim();
|
|
1131
|
+
if (!hostId) return base;
|
|
1132
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
1133
|
+
return `${base}${sep}host_id=${encodeURIComponent(hostId)}`;
|
|
1134
|
+
},
|
|
1017
1135
|
getApiKey: api.getApiKey,
|
|
1018
1136
|
onEvent: handleWakeEvent,
|
|
1019
1137
|
onStatus: handleWakeStatus,
|
|
@@ -1163,11 +1281,11 @@ export function createTiclawkAdapter(ctx) {
|
|
|
1163
1281
|
id: 'ticlawk',
|
|
1164
1282
|
|
|
1165
1283
|
async start() {
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
// when it happens the fix is a one-row UPDATE in supabase.
|
|
1284
|
+
// Stale claimed deliveries are recovered conservatively by the
|
|
1285
|
+
// claim RPC; this startup path only refreshes local bindings and
|
|
1286
|
+
// asks for the next drain.
|
|
1170
1287
|
await refreshBindings('startup');
|
|
1288
|
+
await syncCredentials('startup');
|
|
1171
1289
|
await requestDrain('startup');
|
|
1172
1290
|
connectWakeSocket();
|
|
1173
1291
|
startAuditTimers();
|
|
@@ -165,7 +165,7 @@ export class TiclawkWakeClient {
|
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
if (type === 'jobs.available' || type === 'bindings.changed') {
|
|
168
|
+
if (type === 'jobs.available' || type === 'bindings.changed' || type === 'credentials.changed') {
|
|
169
169
|
this.onEvent?.(msg);
|
|
170
170
|
return;
|
|
171
171
|
}
|