evot-agent 0.5.3 → 0.6.0
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/bin/evot-agent.js +5 -3
- package/package.json +4 -3
- package/src/backends/claude.js +26 -0
- package/src/backends/code.js +272 -0
- package/src/config.js +17 -1
- package/src/report.js +44 -1
- package/src/sync.js +217 -24
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ evot에서 Vot에게 "내 컴퓨터 브라우저로 ○○ 수집해줘"라고
|
|
|
7
7
|
|
|
8
8
|
## 요구사항
|
|
9
9
|
|
|
10
|
-
- Node
|
|
10
|
+
- Node 22+
|
|
11
11
|
- Google Chrome
|
|
12
12
|
- **백엔드(택1)** — 별도 API 키 불필요:
|
|
13
13
|
- **Claude (기본)**: Claude Code 로그인(구독). 모델 크리덴셜은 로컬 Claude Code 로그인을 그대로 사용. (또는 `ANTHROPIC_API_KEY`)
|
|
@@ -37,7 +37,7 @@ npx evot-agent connect <code> # 예: npx evot-agent connect ABCD2345
|
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
npm install -g evot-agent
|
|
40
|
-
evot-agent start #
|
|
40
|
+
evot-agent start # 상시 연결(초인종). 막히면 폴링 폴백
|
|
41
41
|
evot-agent install # macOS 전용: 로그인 시 자동 시작 + 백그라운드 상주
|
|
42
42
|
evot-agent uninstall # 상주 등록 해제
|
|
43
43
|
```
|
|
@@ -52,7 +52,9 @@ evot-agent uninstall # 상주 등록 해제
|
|
|
52
52
|
- **다른 쓰레드로**: "...and put the results in my 'Research' thread" → 이름으로 대상 쓰레드 지정.
|
|
53
53
|
- **루틴**: 루틴 지시문에 같은 식으로 쓰면 스케줄 도래 시 자동 큐잉·실행.
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
잡이 생기면 서버가 Realtime으로 깨우므로 보통 1초 안에 시작한다. WebSocket이 막힌 환경에서는
|
|
56
|
+
long-poll 폴백으로 내려가며, 그 주기는 `--interval <초>` 또는 `EVOT_AGENT_POLL_SECONDS`로
|
|
57
|
+
조절한다(최소 30초). `EVOT_AGENT_FORCE_POLL=1`이면 초인종을 끄고 폴백만 쓴다(진단용).
|
|
56
58
|
결과는 evot 웹에서 쓰레드 새로고침 시 보이고, 텔레그램 연동 시 완료/실패 알림이 온다.
|
|
57
59
|
로그(상주 등록 시): `~/.evot-agent/logs/agent.log`
|
|
58
60
|
|
package/bin/evot-agent.js
CHANGED
|
@@ -13,10 +13,12 @@ Usage:
|
|
|
13
13
|
|
|
14
14
|
Normal use is "start": leave it running, then ask a Vot (in chat or a routine) to
|
|
15
15
|
"use my computer's browser to …". evot queues the task; this daemon picks it up and
|
|
16
|
-
appends the result as a cell in the target
|
|
16
|
+
appends the result as a cell in the target log. Tasks that need a decision from you
|
|
17
|
+
pause and wait for your answer in evot, then carry on where they left off.
|
|
17
18
|
|
|
18
|
-
start
|
|
19
|
-
|
|
19
|
+
start keeps a live connection to evot, so tasks begin within a second. If that
|
|
20
|
+
connection is blocked (some corporate networks), it falls back to polling; use
|
|
21
|
+
--interval or EVOT_AGENT_POLL_SECONDS to pace the fallback (minimum 30s).
|
|
20
22
|
|
|
21
23
|
Backend (which model drives the browser): defaults to Claude (uses your Claude Code
|
|
22
24
|
login). To use Codex instead (uses your ChatGPT "codex login"), add --backend codex
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evot-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local
|
|
5
|
+
"description": "Local agent for evot — runs tasks on your computer with Claude or Codex and reports back into your notes.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"evot-agent": "bin/evot-agent.js"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=22"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node bin/evot-agent.js"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"@anthropic-ai/claude-agent-sdk": "0.3.202",
|
|
24
24
|
"@openai/codex-sdk": "0.144.1",
|
|
25
25
|
"@playwright/mcp": "0.0.77",
|
|
26
|
+
"@supabase/supabase-js": "^2.116.0",
|
|
26
27
|
"playwright-core": "1.61.1"
|
|
27
28
|
}
|
|
28
29
|
}
|
package/src/backends/claude.js
CHANGED
|
@@ -64,6 +64,32 @@ export async function runClaudeJob(job) {
|
|
|
64
64
|
strictMcpConfig: true,
|
|
65
65
|
// MCP 툴은 프롬프트 없이 실행되도록 자동 승인 유지.
|
|
66
66
|
allowedTools: ["mcp__playwright"],
|
|
67
|
+
// 두 번째 겹: Playwright 외 툴 호출은 hook에서 거부한다. disallowedTools는 이름 목록이라
|
|
68
|
+
// SDK가 새 내장 툴(REPL·Monitor·Workflow 등)을 추가하면 그대로 뚫리고, bypassPermissions라
|
|
69
|
+
// 확인 없이 실행된다. hook은 permissionMode와 무관하게 돌고 툴 목록은 건드리지 않으므로
|
|
70
|
+
// tools:[] 때처럼 Playwright가 사라지는 회귀는 없다(허용 접두어는 allowedTools와 동일).
|
|
71
|
+
hooks: {
|
|
72
|
+
PreToolUse: [
|
|
73
|
+
{
|
|
74
|
+
hooks: [
|
|
75
|
+
async (input) => {
|
|
76
|
+
const name = typeof input?.tool_name === "string" ? input.tool_name : "";
|
|
77
|
+
if (name.startsWith("mcp__playwright__")) return { continue: true };
|
|
78
|
+
console.warn(`[evot-agent] blocked tool call: ${name || "(unknown)"}`);
|
|
79
|
+
return {
|
|
80
|
+
continue: true,
|
|
81
|
+
hookSpecificOutput: {
|
|
82
|
+
hookEventName: "PreToolUse",
|
|
83
|
+
permissionDecision: "deny",
|
|
84
|
+
permissionDecisionReason:
|
|
85
|
+
"evot-agent: only Playwright browser tools are allowed",
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
67
93
|
},
|
|
68
94
|
})) {
|
|
69
95
|
if (message.type === "assistant") {
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { CODE_WORKSPACE_DIR } from "../config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* kind:'code' 잡 — 유저 작업 폴더에서 도는 에이전트 루프.
|
|
7
|
+
*
|
|
8
|
+
* browse 잡(backends/claude.js)과 **정책이 정반대**라 파일을 나눈다. browse는 신뢰할 수 없는
|
|
9
|
+
* 웹 페이지를 읽으므로 bypassPermissions + 툴 3중 잠금(Playwright만)이지만, 여기는 유저 본인이
|
|
10
|
+
* 자기 셀에서 낸 지시를 자기 폴더에서 실행하는 것이라 툴을 열되 **권한을 유저에게 되묻는다**.
|
|
11
|
+
* 두 파일을 합치면 한쪽 정책이 다른 쪽으로 새기 때문에 공유는 프롬프트 상수 수준에서만 한다.
|
|
12
|
+
*
|
|
13
|
+
* 유저에게 묻는 두 경로 (2026-09-13 실측으로 확정 — 자세한 근거는
|
|
14
|
+
* backend/.claude/plans/2026-09-13-로컬-에이전트-phase0-왕복채널-플랜.md §1-A):
|
|
15
|
+
*
|
|
16
|
+
* 1) AskUserQuestion → PreToolUse 훅이 defer → 프로세스가 tool_deferred로 **끝난다**.
|
|
17
|
+
* 서버에 waiting(mode:'deferred')로 보고하고 종료. 유저가 답하면 서버가 pending_resume으로
|
|
18
|
+
* 올리고, 데몬이 다시 claim해 session_id로 --resume 한다. 노트북을 닫아도 안전한 경로다.
|
|
19
|
+
* 2) 권한 프롬프트(폴더 밖 쓰기·일반 셸 명령) → canUseTool로 올라온다. 여기서는 프로세스를
|
|
20
|
+
* 살려 둔 채 waiting(mode:'live')로 보고하고 답을 기다린다. defer가 안 되는 경로라서다
|
|
21
|
+
* (defer는 한 턴에 툴 호출이 하나일 때만 유효하다).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// 한 번의 실행이 붙들 수 있는 최대 시간. maxTurns를 쓰지 않는 이유: 턴 상한에 걸리면
|
|
25
|
+
// error_max_turns로 끝나면서 **결과 텍스트가 0자**가 된다(MAKi 실측). 사람을 기다리는 동안은
|
|
26
|
+
// 이 시계를 멈춘다 — 답이 늦다고 작업을 죽이면 왕복 구조 자체가 무의미해진다.
|
|
27
|
+
const RUN_BUDGET_MS = 30 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
// browse의 SYSTEM_APPEND와 별개. 여기서 강조할 것은 "질문은 한 번만"과 "폴더 밖으로 나가지 마라".
|
|
30
|
+
// 질문 강조가 필요한 이유: defer는 모델에게 툴 내부 오류처럼 보여서, 그냥 두면 같은 턴에서
|
|
31
|
+
// 재시도하며 "The interactive question tool keeps failing"류의 변명을 답에 남긴다(실측).
|
|
32
|
+
const CODE_SYSTEM_APPEND = [
|
|
33
|
+
"You are running on the user's own computer, launched from a note in evot.",
|
|
34
|
+
"",
|
|
35
|
+
"ASKING THE USER:",
|
|
36
|
+
"- When you need the user to decide something, call AskUserQuestion EXACTLY ONCE and stop.",
|
|
37
|
+
"- It may look like the tool failed with an internal error. It did not. Do NOT retry it and",
|
|
38
|
+
" do NOT apologize for it: the user answers elsewhere (in their note) and your session is",
|
|
39
|
+
" resumed automatically with their answer.",
|
|
40
|
+
"- Never guess an answer you decided to ask for.",
|
|
41
|
+
"",
|
|
42
|
+
"SCOPE:",
|
|
43
|
+
"- Stay inside your working directory. Anything outside it, and any shell command, has to be",
|
|
44
|
+
" approved by the user, which interrupts them — so do not reach outside without a real reason.",
|
|
45
|
+
"- If an action is refused, say plainly what you could not do instead of working around it.",
|
|
46
|
+
"",
|
|
47
|
+
"ANSWERING:",
|
|
48
|
+
"- Finish with a short summary of what you actually changed or found. No preamble.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
|
|
51
|
+
/** 서버가 실어 보낸 답을 AskUserQuestion의 updatedInput 형태로 바꾼다. */
|
|
52
|
+
function toQuestionInput(input, answer) {
|
|
53
|
+
const out = { questions: input?.questions ?? [] };
|
|
54
|
+
if (answer.answers) out.answers = answer.answers;
|
|
55
|
+
if (answer.response) out.response = answer.response;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 들고 있는 답이 지금 이 질문에 대한 것인가.
|
|
61
|
+
*
|
|
62
|
+
* 재개 직후 모델이 **같은 질문을 한 번 더 부르는** 일이 잦다(실측: 대기 중이던 툴이 canUseTool로
|
|
63
|
+
* 먼저 replay되고, 그 뒤 모델이 다시 AskUserQuestion을 호출한다). 답을 1회용으로 태우면 그
|
|
64
|
+
* 두 번째 호출이 또 defer돼서, 할 일을 다 해놓고도 잡이 끝나지 않는다.
|
|
65
|
+
* 반대로 무조건 재사용하면 **진짜 새 질문**에 엉뚱한 답이 붙는다.
|
|
66
|
+
* 그래서 질문 텍스트로 대조한다 — 답의 키(=질문 텍스트) 중 하나라도 겹치면 같은 질문으로 본다.
|
|
67
|
+
* 자유 응답(response)은 특정 질문에 매인 값이 아니라 어느 질문에나 쓴다.
|
|
68
|
+
*/
|
|
69
|
+
function answerFitsQuestions(input, answer) {
|
|
70
|
+
if (!answer) return false;
|
|
71
|
+
if (answer.response) return true;
|
|
72
|
+
const asked = (input?.questions ?? []).map((q) => q?.question).filter(Boolean);
|
|
73
|
+
const answered = Object.keys(answer.answers ?? {});
|
|
74
|
+
return asked.some((q) => answered.includes(q));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} job agent-sync가 내려준 잡 (id, instruction, cwd, session_id, answer)
|
|
79
|
+
* @param {object} deps
|
|
80
|
+
* @param {(jobId: string) => Promise<{answer?: object, cancelled?: boolean}>} deps.waitForAnswer
|
|
81
|
+
* 유저 답이 올 때까지 기다린다(long-poll + 초인종). 취소면 {cancelled:true}.
|
|
82
|
+
* @param {(args: {jobId: string, sessionId: string|null, pendingTool: object}) => Promise<unknown>} deps.reportWaiting
|
|
83
|
+
* "물을 게 생겨 멈췄다"를 서버에 알린다. 서버 통신을 주입받는 이유는 sync.js와의 순환
|
|
84
|
+
* import을 피하고(waitForAnswer도 같은 이유), 이 모듈을 서버 없이 시험할 수 있게 하기 위해서다.
|
|
85
|
+
* @returns {Promise<{status:'success'|'error'|'waiting', text?:string, sessionId:string|null}>}
|
|
86
|
+
*/
|
|
87
|
+
export async function runCodeJob(job, { waitForAnswer, reportWaiting }) {
|
|
88
|
+
const cwd = job.cwd || CODE_WORKSPACE_DIR;
|
|
89
|
+
await mkdir(cwd, { recursive: true });
|
|
90
|
+
|
|
91
|
+
let sessionId = job.session_id ?? null;
|
|
92
|
+
// 서버가 실어 보낸 답(재개 잡). 이 실행 동안 들고 있다가 **질문이 맞을 때만** 쓴다
|
|
93
|
+
// (answerFitsQuestions 주석 참고). 새 질문이면 서버가 waiting 보고 때 answer를 비우므로
|
|
94
|
+
// 다음 실행으로 새어 나가지 않는다.
|
|
95
|
+
const pendingAnswer = job.answer ?? null;
|
|
96
|
+
|
|
97
|
+
let deferredTool = null;
|
|
98
|
+
let cancelled = false;
|
|
99
|
+
let finalText = "";
|
|
100
|
+
let isError = false;
|
|
101
|
+
const blockedTools = [];
|
|
102
|
+
|
|
103
|
+
// 실행 예산 시계. 사람을 기다리는 구간에서는 멈춘다.
|
|
104
|
+
const ac = new AbortController();
|
|
105
|
+
let budgetTimer = setTimeout(() => ac.abort(), RUN_BUDGET_MS);
|
|
106
|
+
const pauseClock = () => clearTimeout(budgetTimer);
|
|
107
|
+
const resumeClock = () => {
|
|
108
|
+
budgetTimer = setTimeout(() => ac.abort(), RUN_BUDGET_MS);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** 유저에게 물어보고 답을 받아온다(권한 프롬프트 = live 경로). */
|
|
112
|
+
async function askUserLive(pendingTool) {
|
|
113
|
+
await reportWaiting({ jobId: job.id, sessionId, pendingTool });
|
|
114
|
+
pauseClock();
|
|
115
|
+
try {
|
|
116
|
+
return await waitForAnswer(job.id);
|
|
117
|
+
} finally {
|
|
118
|
+
resumeClock();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
for await (const message of query({
|
|
124
|
+
prompt: job.instruction,
|
|
125
|
+
options: {
|
|
126
|
+
cwd,
|
|
127
|
+
abortController: ac,
|
|
128
|
+
...(sessionId ? { resume: sessionId } : {}),
|
|
129
|
+
// acceptEdits: 작업 폴더 안 편집은 묻지 않고, 폴더 밖·셸은 묻는다.
|
|
130
|
+
// 'auto'를 쓰지 않는 이유 — 실측(2026-09-13, CLI 2.1.202)에서 폴더 밖 쓰기와
|
|
131
|
+
// `rm -rf`까지 묻지 않고 승인해 사실상 bypass였다.
|
|
132
|
+
permissionMode: "acceptEdits",
|
|
133
|
+
// browse 백엔드와 같은 격리 원칙: 유저 전역 훅·MCP·claude.ai 커넥터를 끌어오지 않는다.
|
|
134
|
+
settingSources: [],
|
|
135
|
+
strictMcpConfig: true,
|
|
136
|
+
systemPrompt: { type: "preset", preset: "claude_code", append: CODE_SYSTEM_APPEND },
|
|
137
|
+
hooks: {
|
|
138
|
+
PreToolUse: [
|
|
139
|
+
{
|
|
140
|
+
hooks: [
|
|
141
|
+
async (input) => {
|
|
142
|
+
if (input?.tool_name !== "AskUserQuestion") return { continue: true };
|
|
143
|
+
if (answerFitsQuestions(input.tool_input, pendingAnswer)) {
|
|
144
|
+
return {
|
|
145
|
+
continue: true,
|
|
146
|
+
hookSpecificOutput: {
|
|
147
|
+
hookEventName: "PreToolUse",
|
|
148
|
+
permissionDecision: "allow",
|
|
149
|
+
updatedInput: toQuestionInput(input.tool_input, pendingAnswer),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// 답이 없으면 여기서 프로세스를 끝낸다(tool_deferred). 서버 보고는 루프 밖에서.
|
|
154
|
+
return {
|
|
155
|
+
continue: true,
|
|
156
|
+
hookSpecificOutput: {
|
|
157
|
+
hookEventName: "PreToolUse",
|
|
158
|
+
permissionDecision: "defer",
|
|
159
|
+
permissionDecisionReason: "evot: waiting for the user in their note",
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
// ★ canUseTool을 넘기지 않으면 AskUserQuestion이 툴 목록에서 아예 빠진다(실측).
|
|
168
|
+
// 권한 프롬프트 수신 창구이기도 하다.
|
|
169
|
+
canUseTool: async (toolName, input, opts) => {
|
|
170
|
+
// (1) AskUserQuestion — 재개 직후엔 훅이 아니라 이쪽으로 먼저 온다(실측).
|
|
171
|
+
if (toolName === "AskUserQuestion") {
|
|
172
|
+
if (answerFitsQuestions(input, pendingAnswer)) {
|
|
173
|
+
return { behavior: "allow", updatedInput: toQuestionInput(input, pendingAnswer) };
|
|
174
|
+
}
|
|
175
|
+
// defer가 무시되는 경우(한 턴에 툴 호출 여러 개)의 폴백 — live로 기다린다.
|
|
176
|
+
const res = await askUserLive({
|
|
177
|
+
id: opts?.toolUseID ?? null,
|
|
178
|
+
name: toolName,
|
|
179
|
+
mode: "live",
|
|
180
|
+
input,
|
|
181
|
+
});
|
|
182
|
+
if (res?.cancelled) {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
return { behavior: "deny", message: "The user cancelled this task." };
|
|
185
|
+
}
|
|
186
|
+
if (res?.answer) {
|
|
187
|
+
return { behavior: "allow", updatedInput: toQuestionInput(input, res.answer) };
|
|
188
|
+
}
|
|
189
|
+
return { behavior: "deny", message: "No answer from the user." };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// (2) 권한 프롬프트 — 폴더 밖 쓰기, 일반 셸 명령 등.
|
|
193
|
+
const res = await askUserLive({
|
|
194
|
+
id: opts?.toolUseID ?? null,
|
|
195
|
+
name: toolName,
|
|
196
|
+
mode: "live",
|
|
197
|
+
input,
|
|
198
|
+
reason: opts?.decisionReason ?? null,
|
|
199
|
+
blocked_path: opts?.blockedPath ?? null,
|
|
200
|
+
suggestions: opts?.suggestions ?? null,
|
|
201
|
+
});
|
|
202
|
+
if (res?.cancelled) {
|
|
203
|
+
cancelled = true;
|
|
204
|
+
return { behavior: "deny", message: "The user cancelled this task." };
|
|
205
|
+
}
|
|
206
|
+
if (res?.answer?.decision === "allow") {
|
|
207
|
+
return { behavior: "allow", updatedInput: input };
|
|
208
|
+
}
|
|
209
|
+
// 거부는 모델이 읽는다 — 무엇을 못 했는지 답에 남기도록 이유를 함께 준다.
|
|
210
|
+
blockedTools.push(toolName);
|
|
211
|
+
const why = res?.answer?.message
|
|
212
|
+
? `The user declined: ${res.answer.message}`
|
|
213
|
+
: "The user declined this action on their computer.";
|
|
214
|
+
return { behavior: "deny", message: `${why} Do what you can without it, and say what you could not do.` };
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
})) {
|
|
218
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
219
|
+
// 재개 시에도 같은 값이 온다. 첫 실행에선 여기서만 세션 uuid를 알 수 있다.
|
|
220
|
+
sessionId = message.session_id ?? sessionId;
|
|
221
|
+
} else if (message.type === "assistant") {
|
|
222
|
+
for (const b of message.message?.content ?? []) {
|
|
223
|
+
if (b.type === "text" && b.text.trim()) process.stdout.write(".");
|
|
224
|
+
}
|
|
225
|
+
} else if (message.type === "result") {
|
|
226
|
+
// 판정은 stop_reason으로 한다 — terminal_reason은 같은 상황에서 값이 갈린다(실측).
|
|
227
|
+
if (message.stop_reason === "tool_deferred") {
|
|
228
|
+
deferredTool = message.deferred_tool_use ?? null;
|
|
229
|
+
}
|
|
230
|
+
isError = message.subtype !== "success" || message.is_error === true;
|
|
231
|
+
finalText = message.result ?? "";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(budgetTimer);
|
|
236
|
+
}
|
|
237
|
+
process.stdout.write("\n");
|
|
238
|
+
|
|
239
|
+
if (cancelled) {
|
|
240
|
+
return { status: "error", text: "Cancelled on your computer.", sessionId };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 질문에서 멈췄다 — 이 실행의 어시스턴트 텍스트는 버린다. 결과가 아니라 변명일 뿐이라
|
|
244
|
+
// (모델이 defer를 툴 오류로 읽는다) 셀에 남기면 유저가 실패로 오해한다.
|
|
245
|
+
if (deferredTool) {
|
|
246
|
+
await reportWaiting({
|
|
247
|
+
jobId: job.id,
|
|
248
|
+
sessionId,
|
|
249
|
+
pendingTool: {
|
|
250
|
+
id: deferredTool.id ?? null,
|
|
251
|
+
name: deferredTool.name ?? "AskUserQuestion",
|
|
252
|
+
mode: "deferred",
|
|
253
|
+
input: deferredTool.input ?? {},
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
return { status: "waiting", sessionId };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (isError) {
|
|
260
|
+
const err = new Error(finalText.trim() || "The agent stopped without producing a result.");
|
|
261
|
+
err.sessionId = sessionId;
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let text = finalText.trim();
|
|
266
|
+
if (blockedTools.length > 0) {
|
|
267
|
+
// 거부된 툴은 모델이 답에서 잘 언급하지 않아 "무시했다"로 보인다(MAKi 실측). 꼬리를 붙인다.
|
|
268
|
+
const uniq = [...new Set(blockedTools)].join(", ");
|
|
269
|
+
text += `\n\n_Not allowed on this computer: ${uniq}_`;
|
|
270
|
+
}
|
|
271
|
+
return { status: "success", text, sessionId };
|
|
272
|
+
}
|
package/src/config.js
CHANGED
|
@@ -5,14 +5,30 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
5
5
|
export const AGENT_DIR = join(homedir(), ".evot-agent");
|
|
6
6
|
export const TOKEN_PATH = join(AGENT_DIR, "token.json");
|
|
7
7
|
export const PROFILE_DIR = join(AGENT_DIR, "profile");
|
|
8
|
+
// code 잡(작업 폴더에서 도는 에이전트)의 기본 작업 폴더. 잡이 cwd를 주지 않으면 여기서 돈다.
|
|
9
|
+
export const CODE_WORKSPACE_DIR = join(AGENT_DIR, "workspace");
|
|
10
|
+
|
|
11
|
+
// 서버에 밝히는 데몬 버전(X-Evot-Agent-Version). agent-sync가 이 값으로 code 잡을 내려보낼지
|
|
12
|
+
// 정한다 — 구버전 데몬은 kind를 몰라 code 잡을 browse로 잘못 실행하므로 서버가 막는다.
|
|
13
|
+
// package.json과 손으로 맞춘다(데몬은 번들되지 않아 import assertion을 쓰지 않는다).
|
|
14
|
+
export const AGENT_VERSION = "0.6.0";
|
|
8
15
|
|
|
9
16
|
// evot 백엔드 엔드포인트 주소(자격증명 아님). 유저는 아무것도 입력하지 않는다.
|
|
10
17
|
// agent-redeem/agent-report는 verify_jwt=false라 게이트웨이에 anon key가 필요 없다
|
|
11
|
-
// (텔레그램 봇이 telegram-webhook을 anon key 없이 호출하는 것과 동일).
|
|
18
|
+
// (텔레그램 봇이 telegram-webhook을 anon key 없이 호출하는 것과 동일).
|
|
12
19
|
// (스테이징 등 다른 프로젝트에 붙일 때만 env로 덮어쓴다.)
|
|
13
20
|
export const SUPABASE_URL =
|
|
14
21
|
process.env.EVOT_SUPABASE_URL ?? "https://vzkikgdagvxoovmzrrpc.supabase.co";
|
|
15
22
|
|
|
23
|
+
// Realtime 초인종(서버→데몬 깨우기) 구독 전용 공개 키.
|
|
24
|
+
// **자격증명이 아니다** — 웹앱이 브라우저에 그대로 싣는 publishable 키와 같은 값이고, 이 키로는
|
|
25
|
+
// 잡을 읽을 수 없다(AgentJob은 RLS 정책 0개 + service_role 전용 grant). 깨어난 뒤 실제 잡은
|
|
26
|
+
// 기존 evat_ 토큰으로 agent-sync를 불러 가져온다. 구독 토픽에 실리는 것도 job_id뿐이다.
|
|
27
|
+
// 위 주석의 "로컬엔 anon key를 두지 않는다"가 여기서 바뀐 셈인데, 이유는 "필요가 없어서"였지
|
|
28
|
+
// "비밀이라서"가 아니었다. 초인종에는 WebSocket 연결이 필요해 게이트웨이 키를 쓴다.
|
|
29
|
+
export const SUPABASE_PUBLISHABLE_KEY =
|
|
30
|
+
process.env.EVOT_SUPABASE_KEY ?? "sb_publishable_dvIijfq1hJbtUUcLGXCNZw_wjp3KYdM";
|
|
31
|
+
|
|
16
32
|
/** evot에서 발급한 전용 토큰. 계정 비밀번호/세션은 저장하지 않는다. */
|
|
17
33
|
export async function loadToken() {
|
|
18
34
|
let raw;
|
package/src/report.js
CHANGED
|
@@ -8,12 +8,22 @@ import { SUPABASE_URL, loadToken } from "./config.js";
|
|
|
8
8
|
* - 큐 경로(데몬): jobId만 넘긴다. 서버가 잡에서 log_id를 찾아 셀을 만들고 잡 상태를 done/failed로 갱신.
|
|
9
9
|
* - 수동 경로(run): logId를 넘긴다. (Phase 1 호환)
|
|
10
10
|
*/
|
|
11
|
-
export async function reportResult({
|
|
11
|
+
export async function reportResult({
|
|
12
|
+
logId,
|
|
13
|
+
jobId,
|
|
14
|
+
text,
|
|
15
|
+
segments,
|
|
16
|
+
title,
|
|
17
|
+
status = "success",
|
|
18
|
+
sessionId,
|
|
19
|
+
}) {
|
|
12
20
|
const token = await loadToken();
|
|
13
21
|
|
|
14
22
|
const body = { text, title, status };
|
|
15
23
|
if (jobId) body.job_id = jobId;
|
|
16
24
|
if (logId) body.log_id = logId;
|
|
25
|
+
// Claude Code 세션 uuid — 서버가 재개(--resume) 키로 보관한다. browse 잡은 안 보낸다.
|
|
26
|
+
if (sessionId) body.session_id = sessionId;
|
|
17
27
|
// 섹션(설명+이미지 URL) 전달 — 서버가 votResponse 서브셀·이미지 저장에 사용.
|
|
18
28
|
// 구서버는 이 필드를 무시하고 text만 쓴다(하위호환).
|
|
19
29
|
if (Array.isArray(segments) && segments.length > 0) body.segments = segments;
|
|
@@ -36,3 +46,36 @@ export async function reportResult({ logId, jobId, text, segments, title, status
|
|
|
36
46
|
}
|
|
37
47
|
return data.cell_id;
|
|
38
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* "유저에게 물을 게 생겨서 멈췄다"를 서버에 알린다 → 잡이 waiting으로 바뀌고, 유저가 답하면
|
|
52
|
+
* 트리거가 데몬을 깨운다. 결과 보고가 아니므로 셀도 알림도 만들어지지 않는다.
|
|
53
|
+
*
|
|
54
|
+
* pendingTool.mode가 이후 경로를 가른다:
|
|
55
|
+
* - "deferred": AskUserQuestion을 PreToolUse 훅이 defer해 **프로세스가 이미 끝났다**.
|
|
56
|
+
* 답이 오면 서버가 pending_resume으로 올리고, 데몬이 다시 claim해 --resume 한다.
|
|
57
|
+
* - "live": 권한 프롬프트라 canUseTool 안에서 **아직 기다린다**. 답만 집어가면 이어진다.
|
|
58
|
+
*/
|
|
59
|
+
export async function reportWaiting({ jobId, sessionId, pendingTool }) {
|
|
60
|
+
const token = await loadToken();
|
|
61
|
+
|
|
62
|
+
const res = await fetch(`${SUPABASE_URL}/functions/v1/agent-report`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
Authorization: `Bearer ${token}`,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
job_id: jobId,
|
|
70
|
+
status: "waiting",
|
|
71
|
+
session_id: sessionId ?? null,
|
|
72
|
+
pending_tool: pendingTool,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const data = await res.json().catch(() => ({}));
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new Error(`agent-report(waiting) failed (${res.status}): ${data.error ?? "unknown"}`);
|
|
79
|
+
}
|
|
80
|
+
return data;
|
|
81
|
+
}
|
package/src/sync.js
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createClient } from "@supabase/supabase-js";
|
|
2
|
+
import {
|
|
3
|
+
AGENT_VERSION,
|
|
4
|
+
SUPABASE_PUBLISHABLE_KEY,
|
|
5
|
+
SUPABASE_URL,
|
|
6
|
+
loadToken,
|
|
7
|
+
} from "./config.js";
|
|
2
8
|
import { describeJobFailure, runBrowseJob } from "./browse.js";
|
|
3
|
-
import {
|
|
9
|
+
import { runCodeJob } from "./backends/code.js";
|
|
10
|
+
import { reportResult, reportWaiting } from "./report.js";
|
|
4
11
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
12
|
+
// 데몬은 이제 폴링이 아니라 **초인종**으로 깨어난다: 잡이 생기면 DB 트리거가 Realtime broadcast를
|
|
13
|
+
// 쏘고(agent-wake:<user_id>), 데몬이 그걸 받아 즉시 agent-sync를 부른다. 지연이 최대 60초에서
|
|
14
|
+
// 1초대로 줄고, 평소 호출은 0이 된다(폴링을 5초로 줄이면 데몬 한 대가 하루 17,280번 부른다 —
|
|
15
|
+
// 부담은 DB가 아니라 Edge Function 호출 수였다).
|
|
16
|
+
//
|
|
17
|
+
// WebSocket이 막힌 환경(회사 프록시 등)에서는 구독이 실패하고 long-poll 폴백으로 내려간다.
|
|
18
|
+
// 그 경우 agent-sync가 최대 25초 기다렸다 답하므로, 폴링이어도 호출 수는 예전의 1/25 수준이다.
|
|
19
|
+
|
|
20
|
+
// 초인종이 붙어 있을 때의 하트비트 주기. 이 호출이 last_seen_at을 갱신하고, 그 값이 곧
|
|
21
|
+
// "지금 돌고 있는가"의 유일한 근거다(서버 판정 창 5분 — vot-react AGENT_ONLINE_WINDOW_MS).
|
|
22
|
+
const HEARTBEAT_MS = 4 * 60 * 1000;
|
|
23
|
+
// 폴백 모드에서 한 주기의 최소 길이. agent-sync의 long-poll(25초)이 대부분을 채운다.
|
|
24
|
+
const DEFAULT_INTERVAL_SEC = 30;
|
|
9
25
|
const MIN_INTERVAL_SEC = 30;
|
|
26
|
+
// 사람을 기다리는 최대 시간. 이보다 오래 답이 없으면 그 툴 호출을 접는다(프로세스를 영원히
|
|
27
|
+
// 붙들지 않기 위한 상한 — 잡 자체는 waiting으로 남아 나중에 답하면 재개된다).
|
|
28
|
+
const ANSWER_WAIT_MAX_MS = 6 * 60 * 60 * 1000;
|
|
29
|
+
// 오류 후 재시도 간격(폭주 방지).
|
|
30
|
+
const ERROR_BACKOFF_MS = 5_000;
|
|
10
31
|
|
|
11
32
|
function resolveIntervalSec(cliSec) {
|
|
12
33
|
const fromEnv = Number(process.env.EVOT_AGENT_POLL_SECONDS);
|
|
@@ -20,15 +41,17 @@ function resolveIntervalSec(cliSec) {
|
|
|
20
41
|
|
|
21
42
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
43
|
|
|
23
|
-
/** agent-sync
|
|
24
|
-
async function
|
|
44
|
+
/** agent-sync 호출. 401은 code로 구분해 던진다(토큰 폐기 → 데몬 정상 종료). */
|
|
45
|
+
async function syncCall(token, body) {
|
|
25
46
|
const res = await fetch(`${SUPABASE_URL}/functions/v1/agent-sync`, {
|
|
26
47
|
method: "POST",
|
|
27
48
|
headers: {
|
|
28
49
|
"Content-Type": "application/json",
|
|
29
50
|
Authorization: `Bearer ${token}`,
|
|
51
|
+
// 서버는 이 값으로 code 잡을 내려보낼지 정한다(구버전 데몬 보호).
|
|
52
|
+
"X-Evot-Agent-Version": AGENT_VERSION,
|
|
30
53
|
},
|
|
31
|
-
body:
|
|
54
|
+
body: JSON.stringify(body),
|
|
32
55
|
});
|
|
33
56
|
const data = await res.json().catch(() => ({}));
|
|
34
57
|
if (res.status === 401) {
|
|
@@ -39,16 +62,122 @@ async function fetchJobs(token) {
|
|
|
39
62
|
if (!res.ok) {
|
|
40
63
|
throw new Error(`agent-sync failed (${res.status}): ${data.error ?? "unknown"}`);
|
|
41
64
|
}
|
|
42
|
-
return
|
|
65
|
+
return data;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 유저 답을 기다린다(권한 프롬프트 = live 경로). 서버가 한 번에 최대 25초 잡아 두므로
|
|
70
|
+
* 이 루프는 사실상 대기다. 초인종이 답을 알리면 서버 쪽 long-poll이 즉시 풀린다.
|
|
71
|
+
*/
|
|
72
|
+
async function waitForAnswer(token, jobId) {
|
|
73
|
+
const deadline = Date.now() + ANSWER_WAIT_MAX_MS;
|
|
74
|
+
for (;;) {
|
|
75
|
+
let data;
|
|
76
|
+
try {
|
|
77
|
+
data = await syncCall(token, { job_id: jobId });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err.code === 401) throw err;
|
|
80
|
+
// 일시적 네트워크 오류로 유저 답을 잃지 않도록 물러섰다 재시도한다.
|
|
81
|
+
console.error(`Waiting for your answer (will retry): ${err.message}`);
|
|
82
|
+
await sleep(ERROR_BACKOFF_MS);
|
|
83
|
+
if (Date.now() >= deadline) return {};
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (data.cancelled) return { cancelled: true };
|
|
87
|
+
if (data.answer) return { answer: data.answer };
|
|
88
|
+
if (data.gone) return {};
|
|
89
|
+
if (Date.now() >= deadline) return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 깨우기 신호. 대기 중이 아닐 때 온 신호도 흘리지 않도록 pending으로 들고 있는다
|
|
95
|
+
* (잡 실행 중에 다음 잡이 들어오는 흔한 경우).
|
|
96
|
+
*/
|
|
97
|
+
function createWaker() {
|
|
98
|
+
let pending = false;
|
|
99
|
+
let resolve = null;
|
|
100
|
+
return {
|
|
101
|
+
signal() {
|
|
102
|
+
if (resolve) {
|
|
103
|
+
const r = resolve;
|
|
104
|
+
resolve = null;
|
|
105
|
+
r("wake");
|
|
106
|
+
} else {
|
|
107
|
+
pending = true;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
wait(ms) {
|
|
111
|
+
if (pending) {
|
|
112
|
+
pending = false;
|
|
113
|
+
return Promise.resolve("wake");
|
|
114
|
+
}
|
|
115
|
+
return new Promise((res) => {
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
resolve = null;
|
|
118
|
+
res("timeout");
|
|
119
|
+
}, ms);
|
|
120
|
+
resolve = (v) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
res(v);
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
43
127
|
}
|
|
44
128
|
|
|
45
129
|
/** 잡 1건 실행 → 결과(또는 실패 사유)를 job_id로 보고. 보고는 서버가 셀 생성 + 잡 상태 갱신. */
|
|
46
|
-
async function runJob(job, localBackend) {
|
|
47
|
-
|
|
130
|
+
async function runJob(job, localBackend, token) {
|
|
131
|
+
const kind = job.kind ?? "browse";
|
|
132
|
+
console.log(`\nJob ${job.id} (${kind}): ${String(job.instruction ?? "").slice(0, 80)}`);
|
|
133
|
+
|
|
134
|
+
if (kind === "code") {
|
|
135
|
+
// 우선순위: 로컬 오버라이드 > 서버(웹 토글)가 지정한 백엔드 > claude.
|
|
136
|
+
const backend = localBackend ?? job.backend ?? "claude";
|
|
137
|
+
if (backend !== "claude") {
|
|
138
|
+
// Codex SDK의 thread.run에는 대화형 승인·defer가 없어 이 왕복을 만들 수 없다.
|
|
139
|
+
await reportResult({
|
|
140
|
+
jobId: job.id,
|
|
141
|
+
text: `This task needs the Claude runtime. Switch the runtime to Claude in evot Settings, or start the agent with --backend claude.`,
|
|
142
|
+
title: "Local task",
|
|
143
|
+
status: "error",
|
|
144
|
+
});
|
|
145
|
+
console.error(`Job ${job.id} skipped: code jobs need the Claude runtime.`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const { status, text, sessionId } = await runCodeJob(job, {
|
|
150
|
+
waitForAnswer: (jobId) => waitForAnswer(token, jobId),
|
|
151
|
+
reportWaiting,
|
|
152
|
+
});
|
|
153
|
+
if (status === "waiting") {
|
|
154
|
+
// code.js가 이미 waiting을 보고했다. 여기서 더 할 일이 없다 — 유저가 답하면 초인종이 온다.
|
|
155
|
+
console.log(`Job ${job.id} is waiting for your answer in evot.`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await reportResult({ jobId: job.id, text, title: "Local task", status, sessionId });
|
|
159
|
+
console.log(`Reported job ${job.id}.`);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error(`Job ${job.id} failed: ${err.message}`);
|
|
162
|
+
try {
|
|
163
|
+
await reportResult({
|
|
164
|
+
jobId: job.id,
|
|
165
|
+
text: err.message,
|
|
166
|
+
title: "Local task",
|
|
167
|
+
status: "error",
|
|
168
|
+
sessionId: err.sessionId,
|
|
169
|
+
});
|
|
170
|
+
} catch (reportErr) {
|
|
171
|
+
console.error(`Failed to report job ${job.id}: ${reportErr.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── browse 잡(기존 Computer Use) — 정책·프롬프트가 완전히 다른 경로다 ──
|
|
48
178
|
let text;
|
|
49
179
|
let segments;
|
|
50
180
|
let status = "success";
|
|
51
|
-
// 우선순위: 로컬 오버라이드(--backend/env) > 서버(웹 토글)가 지정한 job.backend > 기본 claude.
|
|
52
181
|
const backend = localBackend ?? job.backend;
|
|
53
182
|
try {
|
|
54
183
|
({ text, segments } = await runBrowseJob(
|
|
@@ -76,18 +205,61 @@ async function runJob(job, localBackend) {
|
|
|
76
205
|
}
|
|
77
206
|
|
|
78
207
|
/**
|
|
79
|
-
* 데몬
|
|
208
|
+
* 데몬 모드. 초인종(Realtime)으로 깨어나 잡을 실행하고, 구독이 안 되면 long-poll로 폴백한다.
|
|
80
209
|
* claim은 한 번에 1건씩 내려오므로, 잡이 남아 있는 동안은 대기 없이 연속으로 소진한다.
|
|
81
210
|
*/
|
|
82
211
|
export async function runStart(opts = {}) {
|
|
83
212
|
const token = await loadToken(); // 미연결이면 여기서 throw
|
|
84
|
-
const
|
|
213
|
+
const intervalMs = resolveIntervalSec(opts.intervalSec) * 1000;
|
|
85
214
|
// 로컬 오버라이드: --backend 플래그 > EVOT_AGENT_BACKEND env. 둘 다 없거나 빈 문자열이면
|
|
86
215
|
// undefined → 잡별로 서버(웹 토글)가 준 backend를 따른다(|| 로 빈 문자열도 미설정 취급).
|
|
87
216
|
const localBackend = opts.backend || process.env.EVOT_AGENT_BACKEND || undefined;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
217
|
+
// 검증·문제 진단용: 초인종을 끄고 폴백 경로만 태운다.
|
|
218
|
+
const forcePoll = process.env.EVOT_AGENT_FORCE_POLL === "1";
|
|
219
|
+
|
|
220
|
+
const waker = createWaker();
|
|
221
|
+
let realtimeOk = false;
|
|
222
|
+
let subscribedTopic = null;
|
|
223
|
+
let supabase = null;
|
|
224
|
+
|
|
225
|
+
function ensureSubscription(topic) {
|
|
226
|
+
if (forcePoll || !topic || topic === subscribedTopic) return;
|
|
227
|
+
subscribedTopic = topic;
|
|
228
|
+
supabase ??= createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
|
|
229
|
+
supabase
|
|
230
|
+
.channel(topic)
|
|
231
|
+
.on("broadcast", { event: "wake" }, () => waker.signal())
|
|
232
|
+
.subscribe((status) => {
|
|
233
|
+
const wasOk = realtimeOk;
|
|
234
|
+
realtimeOk = status === "SUBSCRIBED";
|
|
235
|
+
if (realtimeOk && !wasOk) {
|
|
236
|
+
console.log("Connected to evot. Tasks will start within a second.");
|
|
237
|
+
// 구독이 붙는 사이 들어온 잡이 있을 수 있다.
|
|
238
|
+
waker.signal();
|
|
239
|
+
} else if (!realtimeOk && wasOk) {
|
|
240
|
+
console.log(`Live connection lost (${status}). Falling back to polling.`);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log("evot-agent is running. Press Ctrl+C to stop.");
|
|
246
|
+
|
|
247
|
+
// 부트스트랩: **대기 없는** 첫 호출로 wake_topic을 받아 구독부터 붙인다.
|
|
248
|
+
// 이 한 번을 건너뛰고 곧장 루프에 들어가면, 잡이 없을 때 첫 long-poll(25초)이 끝나야
|
|
249
|
+
// 구독이 시작된다 — 그 사이엔 초인종이 없는 셈이라 "1초 안에 시작"이 지켜지지 않는다.
|
|
250
|
+
try {
|
|
251
|
+
const first = await syncCall(token, {});
|
|
252
|
+
ensureSubscription(first.wake_topic);
|
|
253
|
+
// 구독이 붙을 짧은 여유. 붙으면 아래 루프가 long-poll 대신 초인종을 기다린다.
|
|
254
|
+
for (let i = 0; i < 20 && !realtimeOk && !forcePoll; i++) await sleep(100);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (err.code === 401) {
|
|
257
|
+
console.error(`\nToken rejected: ${err.message}`);
|
|
258
|
+
console.error(`Reconnect with "evot-agent connect <code>" (get a code from evot.io).`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
console.error(`Could not reach evot (will retry): ${err.message}`);
|
|
262
|
+
}
|
|
91
263
|
|
|
92
264
|
let stop = false;
|
|
93
265
|
process.on("SIGINT", () => {
|
|
@@ -100,14 +272,20 @@ export async function runStart(opts = {}) {
|
|
|
100
272
|
});
|
|
101
273
|
|
|
102
274
|
while (!stop) {
|
|
275
|
+
const cycleStart = Date.now();
|
|
103
276
|
try {
|
|
104
|
-
// 잡이 없을 때까지 연속으로 가져와
|
|
105
|
-
|
|
106
|
-
|
|
277
|
+
// 잡이 없을 때까지 연속으로 가져와 실행.
|
|
278
|
+
// 초인종이 붙어 있으면 즉답(빈 응답)으로 끝내고 아래에서 대기하고, 폴백이면 서버가
|
|
279
|
+
// 최대 25초 잡아 두는 long-poll로 부른다.
|
|
280
|
+
for (;;) {
|
|
281
|
+
if (stop) break;
|
|
282
|
+
const data = await syncCall(token, { wait: !realtimeOk });
|
|
283
|
+
ensureSubscription(data.wake_topic);
|
|
284
|
+
const jobs = Array.isArray(data.jobs) ? data.jobs : [];
|
|
107
285
|
if (jobs.length === 0) break;
|
|
108
286
|
for (const job of jobs) {
|
|
109
287
|
if (stop) break;
|
|
110
|
-
await runJob(job, localBackend);
|
|
288
|
+
await runJob(job, localBackend, token);
|
|
111
289
|
}
|
|
112
290
|
}
|
|
113
291
|
} catch (err) {
|
|
@@ -116,10 +294,25 @@ export async function runStart(opts = {}) {
|
|
|
116
294
|
console.error(`Reconnect with "evot-agent connect <code>" (get a code from evot.io).`);
|
|
117
295
|
return;
|
|
118
296
|
}
|
|
119
|
-
console.error(`
|
|
297
|
+
console.error(`Sync error (will retry): ${err.message}`);
|
|
298
|
+
await sleep(ERROR_BACKOFF_MS);
|
|
120
299
|
}
|
|
121
300
|
if (stop) break;
|
|
122
|
-
|
|
301
|
+
|
|
302
|
+
if (realtimeOk) {
|
|
303
|
+
// 깨우거나, 하트비트 주기가 되거나.
|
|
304
|
+
await waker.wait(HEARTBEAT_MS);
|
|
305
|
+
} else {
|
|
306
|
+
// long-poll이 이미 대부분을 소비했다. 유저가 주기를 길게 잡았으면 그만큼만 더 쉰다.
|
|
307
|
+
const rest = intervalMs - (Date.now() - cycleStart);
|
|
308
|
+
if (rest > 0) await waker.wait(rest);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await supabase?.removeAllChannels();
|
|
314
|
+
} catch {
|
|
315
|
+
// 종료 경로라 무시한다.
|
|
123
316
|
}
|
|
124
317
|
console.log("evot-agent stopped.");
|
|
125
318
|
}
|