@1mancompany/onemancompany 0.7.73 → 0.7.76
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/frontend/app.js +101 -6
- package/frontend/style.css +33 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/onemancompany/agents/base.py +22 -4
- package/src/onemancompany/agents/onboarding.py +11 -5
- package/src/onemancompany/agents/recruitment.py +4 -0
- package/src/onemancompany/api/routes.py +51 -17
- package/src/onemancompany/core/config.py +97 -7
- package/src/onemancompany/core/task_persistence.py +54 -11
- package/src/onemancompany/core/task_tree.py +11 -0
- package/src/onemancompany/core/vessel.py +158 -24
- package/src/onemancompany/main.py +9 -1
package/frontend/app.js
CHANGED
|
@@ -32,6 +32,8 @@ class AppController {
|
|
|
32
32
|
this._viewingBoardProjectId = null;
|
|
33
33
|
// Unread message counts per channel (channelId → count)
|
|
34
34
|
this._unreadCounts = {};
|
|
35
|
+
// Cached company defaults for candidate hire warnings/choices
|
|
36
|
+
this._companyLlmDefaults = null;
|
|
35
37
|
// Initialize plugin system before connecting
|
|
36
38
|
window.pluginLoader.init().then(() => {
|
|
37
39
|
this.connect();
|
|
@@ -3903,7 +3905,7 @@ class AppController {
|
|
|
3903
3905
|
for (const [batchId, batch] of Object.entries(batches)) {
|
|
3904
3906
|
const candidates = batch.candidates || [];
|
|
3905
3907
|
if (candidates.length > 0) {
|
|
3906
|
-
this.showCandidateSelection({
|
|
3908
|
+
await this.showCandidateSelection({
|
|
3907
3909
|
batch_id: batchId,
|
|
3908
3910
|
candidates,
|
|
3909
3911
|
roles: batch.roles || [],
|
|
@@ -3916,11 +3918,63 @@ class AppController {
|
|
|
3916
3918
|
}
|
|
3917
3919
|
}
|
|
3918
3920
|
|
|
3919
|
-
|
|
3921
|
+
async _loadCompanyLlmDefaults(force = false) {
|
|
3922
|
+
if (this._companyLlmDefaults && !force) return this._companyLlmDefaults;
|
|
3923
|
+
try {
|
|
3924
|
+
const data = await fetch('/api/settings/api').then(r => r.json());
|
|
3925
|
+
this._companyLlmDefaults = {
|
|
3926
|
+
provider: data.default_provider || 'openrouter',
|
|
3927
|
+
model: data.default_model || '',
|
|
3928
|
+
};
|
|
3929
|
+
} catch (e) {
|
|
3930
|
+
console.warn('Failed to load company LLM defaults for hiring UI:', e);
|
|
3931
|
+
this._companyLlmDefaults = { provider: 'openrouter', model: '' };
|
|
3932
|
+
}
|
|
3933
|
+
return this._companyLlmDefaults;
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
_getCandidateLlmWarning(candidate) {
|
|
3937
|
+
const defaults = this._companyLlmDefaults;
|
|
3938
|
+
if (!defaults) return null;
|
|
3939
|
+
const hosting = candidate.hosting || 'company';
|
|
3940
|
+
if (hosting === 'self' || hosting === 'remote') return null;
|
|
3941
|
+
const talentProvider = candidate.api_provider || 'openrouter';
|
|
3942
|
+
const talentModel = candidate.llm_model || '';
|
|
3943
|
+
const providerDiffers = talentProvider !== defaults.provider;
|
|
3944
|
+
const modelDiffers = Boolean(defaults.model && talentModel && talentModel !== defaults.model);
|
|
3945
|
+
if (!providerDiffers && !modelDiffers) return null;
|
|
3946
|
+
return {
|
|
3947
|
+
talentProvider,
|
|
3948
|
+
talentModel,
|
|
3949
|
+
companyProvider: defaults.provider,
|
|
3950
|
+
companyModel: defaults.model,
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
_getCandidateConfigChoice(candidateId) {
|
|
3955
|
+
return Boolean(this._candidateHireConfigChoices?.get(candidateId));
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
_setCandidateConfigChoice(candidateId, enabled) {
|
|
3959
|
+
if (!this._candidateHireConfigChoices) this._candidateHireConfigChoices = new Map();
|
|
3960
|
+
this._candidateHireConfigChoices.set(candidateId, Boolean(enabled));
|
|
3961
|
+
if (this._selectedCandidates?.has(candidateId)) {
|
|
3962
|
+
const selected = this._selectedCandidates.get(candidateId);
|
|
3963
|
+
this._selectedCandidates.set(candidateId, {
|
|
3964
|
+
...selected,
|
|
3965
|
+
use_talent_llm_config: Boolean(enabled),
|
|
3966
|
+
});
|
|
3967
|
+
this._updateBatchBar();
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
async showCandidateSelection(payload) {
|
|
3972
|
+
await this._loadCompanyLlmDefaults(true);
|
|
3920
3973
|
this._candidateBatchId = payload.batch_id;
|
|
3921
3974
|
this._candidateList = payload.candidates || [];
|
|
3922
3975
|
this._candidateRoles = payload.roles || [];
|
|
3923
3976
|
this._selectedCandidates = new Map(); // candidateId -> {candidate, role}
|
|
3977
|
+
this._candidateHireConfigChoices = new Map(); // candidateId -> bool
|
|
3924
3978
|
this._interviewingCandidate = null;
|
|
3925
3979
|
|
|
3926
3980
|
// If no roles structure, wrap flat candidates into a single role group
|
|
@@ -3994,6 +4048,10 @@ class AppController {
|
|
|
3994
4048
|
const familyLabels = { company: '🧠 LangChain', self: '🤖 Claude', openclaw: '🦞 OpenClaw' };
|
|
3995
4049
|
const hostingLabel = esc(familyLabels[hosting] || hosting);
|
|
3996
4050
|
const authLabel = esc(c.auth_method === 'oauth' ? 'OAuth' : 'API Key');
|
|
4051
|
+
const llmWarning = this._getCandidateLlmWarning(c);
|
|
4052
|
+
const warningBadge = llmWarning
|
|
4053
|
+
? `<div class="card-config-warning" title="${esc(`Company default: ${llmWarning.companyProvider}${llmWarning.companyModel ? ` / ${llmWarning.companyModel}` : ''}\nTalent prefers: ${llmWarning.talentProvider}${llmWarning.talentModel ? ` / ${llmWarning.talentModel}` : ''}`)}">⚠ Different company defaults</div>`
|
|
4054
|
+
: '';
|
|
3997
4055
|
|
|
3998
4056
|
card.innerHTML = `
|
|
3999
4057
|
<div class="card-inner">
|
|
@@ -4009,6 +4067,7 @@ class AppController {
|
|
|
4009
4067
|
<span class="score-label">${scorePct}%</span>
|
|
4010
4068
|
</div>
|
|
4011
4069
|
${reasoning ? `<div class="card-reasoning" title="${esc(reasoning)}">${esc(reasoning.substring(0, 40))}${reasoning.length > 40 ? '...' : ''}</div>` : ''}
|
|
4070
|
+
${warningBadge}
|
|
4012
4071
|
<div class="card-cost">${costPer1m} | ${hiringFee}</div>
|
|
4013
4072
|
<div class="card-hosting">${hostingLabel}</div>
|
|
4014
4073
|
</div>
|
|
@@ -4063,7 +4122,11 @@ class AppController {
|
|
|
4063
4122
|
this._selectedCandidates.delete(candidateId);
|
|
4064
4123
|
cardEl.classList.remove('selected');
|
|
4065
4124
|
} else {
|
|
4066
|
-
this._selectedCandidates.set(candidateId, {
|
|
4125
|
+
this._selectedCandidates.set(candidateId, {
|
|
4126
|
+
candidate,
|
|
4127
|
+
role,
|
|
4128
|
+
use_talent_llm_config: this._getCandidateConfigChoice(candidateId),
|
|
4129
|
+
});
|
|
4067
4130
|
cardEl.classList.add('selected');
|
|
4068
4131
|
}
|
|
4069
4132
|
this._updateBatchBar();
|
|
@@ -4100,6 +4163,21 @@ class AppController {
|
|
|
4100
4163
|
const hostingLabel = esc(familyLabels[hosting] || hosting);
|
|
4101
4164
|
const authLabel = esc(c.auth_method === 'oauth' ? 'OAuth' : 'API Key');
|
|
4102
4165
|
const reasoning = c.reasoning || '';
|
|
4166
|
+
const llmWarning = this._getCandidateLlmWarning(c);
|
|
4167
|
+
const useTalentConfig = this._getCandidateConfigChoice(candidateId);
|
|
4168
|
+
const warningSection = llmWarning ? `
|
|
4169
|
+
<div class="detail-section">
|
|
4170
|
+
<div class="detail-label">Config Warning</div>
|
|
4171
|
+
<div class="detail-config-warning-box">
|
|
4172
|
+
<div>Company default: <strong>${esc(llmWarning.companyProvider)}${llmWarning.companyModel ? ` / ${esc(llmWarning.companyModel)}` : ''}</strong></div>
|
|
4173
|
+
<div>Talent prefers: <strong>${esc(llmWarning.talentProvider)}${llmWarning.talentModel ? ` / ${esc(llmWarning.talentModel)}` : ''}</strong></div>
|
|
4174
|
+
<div class="detail-config-warning-note">By default this hire keeps the company setting. Enable the toggle below to hire with the talent's preferred provider/model.</div>
|
|
4175
|
+
</div>
|
|
4176
|
+
<label class="detail-config-toggle">
|
|
4177
|
+
<input id="detail-use-talent-config" type="checkbox" ${useTalentConfig ? 'checked' : ''} />
|
|
4178
|
+
Use talent preferred provider/model for this hire
|
|
4179
|
+
</label>
|
|
4180
|
+
</div>` : '';
|
|
4103
4181
|
|
|
4104
4182
|
content.innerHTML = `
|
|
4105
4183
|
<div class="detail-header">
|
|
@@ -4117,6 +4195,7 @@ class AppController {
|
|
|
4117
4195
|
${tags ? `<div class="detail-section"><div class="detail-label">Personality</div><div class="detail-tags-list">${tags}</div></div>` : ''}
|
|
4118
4196
|
<div class="detail-section"><div class="detail-label">Skills</div><div class="detail-skills-list">${skills || '<em>N/A</em>'}</div></div>
|
|
4119
4197
|
${tools ? `<div class="detail-section"><div class="detail-label">Tools</div><div class="detail-tools-list">${tools}</div></div>` : ''}
|
|
4198
|
+
${warningSection}
|
|
4120
4199
|
<div class="detail-section detail-grid">
|
|
4121
4200
|
<div><div class="detail-label">LLM Model</div><div class="detail-text">🤖 ${esc(llmModel)}</div></div>
|
|
4122
4201
|
<div><div class="detail-label">Provider</div><div class="detail-text">${esc(c.api_provider || 'openrouter')}</div></div>
|
|
@@ -4144,6 +4223,7 @@ class AppController {
|
|
|
4144
4223
|
const newInterviewBtn = document.getElementById('detail-interview-btn');
|
|
4145
4224
|
const newSelectBtn = document.getElementById('detail-select-btn');
|
|
4146
4225
|
const newCloseBtn = document.getElementById('detail-panel-close');
|
|
4226
|
+
const useTalentConfigCheckbox = document.getElementById('detail-use-talent-config');
|
|
4147
4227
|
newSelectBtn.textContent = isSelected ? '✗ Deselect' : '✔ Select';
|
|
4148
4228
|
newSelectBtn.className = isSelected ? 'pixel-btn danger' : 'pixel-btn secondary';
|
|
4149
4229
|
// Only remote (self-hosted) candidates support interview
|
|
@@ -4167,6 +4247,11 @@ class AppController {
|
|
|
4167
4247
|
newSelectBtn.textContent = nowSelected ? '✗ Deselect' : '✔ Select';
|
|
4168
4248
|
newSelectBtn.className = nowSelected ? 'pixel-btn danger' : 'pixel-btn secondary';
|
|
4169
4249
|
});
|
|
4250
|
+
if (useTalentConfigCheckbox) {
|
|
4251
|
+
useTalentConfigCheckbox.addEventListener('change', () => {
|
|
4252
|
+
this._setCandidateConfigChoice(candidateId, useTalentConfigCheckbox.checked);
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4170
4255
|
newCloseBtn.addEventListener('click', () => {
|
|
4171
4256
|
panel.classList.add('hidden');
|
|
4172
4257
|
cardEl.classList.remove('detail-active');
|
|
@@ -4183,7 +4268,11 @@ class AppController {
|
|
|
4183
4268
|
|
|
4184
4269
|
if (count > 0) {
|
|
4185
4270
|
bar.classList.remove('hidden');
|
|
4186
|
-
|
|
4271
|
+
const talentConfigCount = [...this._selectedCandidates.values()]
|
|
4272
|
+
.filter(sel => sel.use_talent_llm_config).length;
|
|
4273
|
+
countEl.textContent = talentConfigCount > 0
|
|
4274
|
+
? `${count} selected · ${talentConfigCount} using talent config`
|
|
4275
|
+
: `${count} selected`;
|
|
4187
4276
|
btn.textContent = `RECRUIT PARTY (${count})`;
|
|
4188
4277
|
btn.disabled = false;
|
|
4189
4278
|
} else {
|
|
@@ -4196,8 +4285,12 @@ class AppController {
|
|
|
4196
4285
|
|
|
4197
4286
|
batchHireCandidates() {
|
|
4198
4287
|
const selections = [];
|
|
4199
|
-
for (const [candidateId, {
|
|
4200
|
-
selections.push({
|
|
4288
|
+
for (const [candidateId, { role }] of this._selectedCandidates) {
|
|
4289
|
+
selections.push({
|
|
4290
|
+
candidate_id: candidateId,
|
|
4291
|
+
role,
|
|
4292
|
+
use_talent_llm_config: this._getCandidateConfigChoice(candidateId),
|
|
4293
|
+
});
|
|
4201
4294
|
}
|
|
4202
4295
|
|
|
4203
4296
|
if (!selections.length) return;
|
|
@@ -4402,6 +4495,7 @@ class AppController {
|
|
|
4402
4495
|
|
|
4403
4496
|
this._interviewingCandidate = null;
|
|
4404
4497
|
this._selectedCandidates = new Map();
|
|
4498
|
+
this._candidateHireConfigChoices = new Map();
|
|
4405
4499
|
|
|
4406
4500
|
// Reset detail panel state so it doesn't flash stale content on reopen
|
|
4407
4501
|
const detailPanel = document.getElementById('candidate-detail-panel');
|
|
@@ -4421,6 +4515,7 @@ class AppController {
|
|
|
4421
4515
|
body: JSON.stringify({
|
|
4422
4516
|
batch_id: this._candidateBatchId,
|
|
4423
4517
|
candidate_id: candidate.id,
|
|
4518
|
+
use_talent_llm_config: this._getCandidateConfigChoice(candidate.talent_id || candidate.id),
|
|
4424
4519
|
}),
|
|
4425
4520
|
})
|
|
4426
4521
|
.then(r => r.json())
|
package/frontend/style.css
CHANGED
|
@@ -2259,6 +2259,15 @@ h3.pixel-title { font-size: var(--text-label); }
|
|
|
2259
2259
|
width: 100%;
|
|
2260
2260
|
max-width: 105px;
|
|
2261
2261
|
}
|
|
2262
|
+
.card-config-warning {
|
|
2263
|
+
margin-top: 4px;
|
|
2264
|
+
padding: 2px 4px;
|
|
2265
|
+
font-size: var(--text-micro);
|
|
2266
|
+
color: #000;
|
|
2267
|
+
background: var(--pixel-yellow);
|
|
2268
|
+
border: 1px solid #000;
|
|
2269
|
+
text-align: center;
|
|
2270
|
+
}
|
|
2262
2271
|
|
|
2263
2272
|
/* ===== Candidate Detail Panel ===== */
|
|
2264
2273
|
.candidate-detail-panel.hidden { display: none; }
|
|
@@ -2297,6 +2306,30 @@ h3.pixel-title { font-size: var(--text-label); }
|
|
|
2297
2306
|
flex: 1;
|
|
2298
2307
|
min-height: 0;
|
|
2299
2308
|
}
|
|
2309
|
+
.detail-config-warning-box {
|
|
2310
|
+
margin-top: 4px;
|
|
2311
|
+
padding: 6px;
|
|
2312
|
+
border: 1px solid var(--pixel-yellow);
|
|
2313
|
+
background: rgba(255, 204, 0, 0.08);
|
|
2314
|
+
color: var(--pixel-white);
|
|
2315
|
+
font-size: var(--text-body);
|
|
2316
|
+
line-height: 1.5;
|
|
2317
|
+
}
|
|
2318
|
+
.detail-config-warning-note {
|
|
2319
|
+
margin-top: 6px;
|
|
2320
|
+
color: var(--text-dim);
|
|
2321
|
+
}
|
|
2322
|
+
.detail-config-toggle {
|
|
2323
|
+
display: flex;
|
|
2324
|
+
align-items: center;
|
|
2325
|
+
gap: 6px;
|
|
2326
|
+
margin-top: 8px;
|
|
2327
|
+
color: var(--pixel-yellow);
|
|
2328
|
+
font-size: var(--text-body);
|
|
2329
|
+
}
|
|
2330
|
+
.detail-config-toggle input {
|
|
2331
|
+
margin: 0;
|
|
2332
|
+
}
|
|
2300
2333
|
.detail-header {
|
|
2301
2334
|
display: flex;
|
|
2302
2335
|
align-items: center;
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -45,7 +45,7 @@ from onemancompany.core.config import (
|
|
|
45
45
|
load_employee_skills,
|
|
46
46
|
read_text_utf,
|
|
47
47
|
)
|
|
48
|
-
from onemancompany.core.models import AuthMethod
|
|
48
|
+
from onemancompany.core.models import AuthMethod, HostingMode
|
|
49
49
|
from onemancompany.core.events import CompanyEvent, event_bus
|
|
50
50
|
from onemancompany.core.state import company_state
|
|
51
51
|
from onemancompany.agents.prompt_builder import PromptBuilder
|
|
@@ -60,6 +60,7 @@ _TC_ATTR = "tool_calls" # AIMessage attribute name
|
|
|
60
60
|
_UNKNOWN_TOOL = "unknown"
|
|
61
61
|
_NO_OUTPUT = "(no output)"
|
|
62
62
|
_MISSING_API_KEY_SENTINEL = "missing-api-key"
|
|
63
|
+
_COMPANY_HOSTING = HostingMode.COMPANY.value
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
def _extract_text(content) -> str:
|
|
@@ -160,6 +161,7 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
|
|
|
160
161
|
effective_temp = 0.7
|
|
161
162
|
api_provider = settings.default_api_provider or "openrouter"
|
|
162
163
|
api_key = ""
|
|
164
|
+
auth_method = ""
|
|
163
165
|
|
|
164
166
|
if employee_id and employee_id in employee_configs:
|
|
165
167
|
cfg = employee_configs[employee_id]
|
|
@@ -168,10 +170,29 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
|
|
|
168
170
|
effective_temp = cfg.temperature
|
|
169
171
|
api_provider = cfg.api_provider
|
|
170
172
|
api_key = cfg.api_key
|
|
173
|
+
auth_method = cfg.auth_method
|
|
171
174
|
|
|
172
175
|
if temperature is not None:
|
|
173
176
|
effective_temp = temperature
|
|
174
177
|
|
|
178
|
+
original_auth_method = auth_method
|
|
179
|
+
llm_profile = {
|
|
180
|
+
"hosting": _COMPANY_HOSTING,
|
|
181
|
+
"api_provider": api_provider,
|
|
182
|
+
"api_key": api_key,
|
|
183
|
+
"llm_model": model,
|
|
184
|
+
"auth_method": auth_method,
|
|
185
|
+
}
|
|
186
|
+
_cfg.normalize_llm_profile_defaults(
|
|
187
|
+
llm_profile,
|
|
188
|
+
reason=f"employee {employee_id}" if employee_id else "company default",
|
|
189
|
+
)
|
|
190
|
+
api_provider = llm_profile.get("api_provider", api_provider)
|
|
191
|
+
model = llm_profile.get("llm_model", model)
|
|
192
|
+
auth_method = llm_profile.get("auth_method", auth_method)
|
|
193
|
+
if not original_auth_method and api_provider == "anthropic":
|
|
194
|
+
auth_method = settings.anthropic_auth_method
|
|
195
|
+
|
|
175
196
|
prov = get_provider(api_provider)
|
|
176
197
|
|
|
177
198
|
# For custom provider, override chat_class from runtime settings
|
|
@@ -183,9 +204,6 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
|
|
|
183
204
|
if prov and effective_chat_class == CHAT_CLASS_ANTHROPIC:
|
|
184
205
|
from langchain_anthropic import ChatAnthropic
|
|
185
206
|
|
|
186
|
-
auth_method = ""
|
|
187
|
-
if employee_id and employee_id in employee_configs:
|
|
188
|
-
auth_method = employee_configs[employee_id].auth_method
|
|
189
207
|
if not auth_method:
|
|
190
208
|
auth_method = settings.anthropic_auth_method
|
|
191
209
|
|
|
@@ -42,6 +42,7 @@ from onemancompany.core.config import (
|
|
|
42
42
|
WORKSPACE_DIR_NAME,
|
|
43
43
|
EmployeeConfig,
|
|
44
44
|
ensure_employee_dir,
|
|
45
|
+
normalize_llm_profile_defaults,
|
|
45
46
|
settings,
|
|
46
47
|
read_text_utf,
|
|
47
48
|
write_text_utf,
|
|
@@ -927,11 +928,16 @@ async def execute_hire(
|
|
|
927
928
|
department, DEFAULT_TOOL_PERMISSIONS_FALLBACK
|
|
928
929
|
))
|
|
929
930
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
931
|
+
llm_profile = {
|
|
932
|
+
"hosting": hosting,
|
|
933
|
+
"api_provider": api_provider,
|
|
934
|
+
"llm_model": llm_model,
|
|
935
|
+
"auth_method": auth_method,
|
|
936
|
+
}
|
|
937
|
+
normalize_llm_profile_defaults(llm_profile, reason=f"new hire {name}")
|
|
938
|
+
api_provider = llm_profile.get("api_provider", "")
|
|
939
|
+
llm_model = llm_profile.get("llm_model", "")
|
|
940
|
+
auth_method = llm_profile.get("auth_method", "api_key")
|
|
935
941
|
|
|
936
942
|
# Salary
|
|
937
943
|
salary = compute_salary(llm_model) if llm_model else 0.0
|
|
@@ -76,6 +76,10 @@ class HireRequest(BaseModel):
|
|
|
76
76
|
batch_id: str = Field(description="Batch ID from the shortlist")
|
|
77
77
|
candidate_id: str = Field(description="ID of the selected candidate")
|
|
78
78
|
nickname: str = Field(default="", description="Optional nickname")
|
|
79
|
+
use_talent_llm_config: bool = Field(
|
|
80
|
+
default=False,
|
|
81
|
+
description="When true, preserve the talent's provider/model instead of company defaults",
|
|
82
|
+
)
|
|
79
83
|
|
|
80
84
|
|
|
81
85
|
class InterviewRequest(BaseModel):
|
|
@@ -4217,7 +4217,14 @@ async def hire_candidate(body: HireRequest) -> dict:
|
|
|
4217
4217
|
|
|
4218
4218
|
# Launch onboarding as background task
|
|
4219
4219
|
spawn_background(
|
|
4220
|
-
_do_hire_single(
|
|
4220
|
+
_do_hire_single(
|
|
4221
|
+
body.batch_id,
|
|
4222
|
+
body.candidate_id,
|
|
4223
|
+
body.nickname,
|
|
4224
|
+
candidate,
|
|
4225
|
+
coo_ctx,
|
|
4226
|
+
use_talent_llm_config=body.use_talent_llm_config,
|
|
4227
|
+
)
|
|
4221
4228
|
)
|
|
4222
4229
|
|
|
4223
4230
|
return {
|
|
@@ -4228,22 +4235,45 @@ async def hire_candidate(body: HireRequest) -> dict:
|
|
|
4228
4235
|
}
|
|
4229
4236
|
|
|
4230
4237
|
|
|
4231
|
-
def _fill_talent_defaults(talent_data: dict) -> None:
|
|
4232
|
-
"""
|
|
4238
|
+
def _fill_talent_defaults(talent_data: dict, *, use_talent_llm_config: bool = False) -> None:
|
|
4239
|
+
"""Normalize Talent Market LLM config against company defaults.
|
|
4233
4240
|
|
|
4234
|
-
|
|
4235
|
-
|
|
4241
|
+
Company-hosted hires use the company's provider/model by default so newly
|
|
4242
|
+
hired employees run on the configured company stack. When the CEO
|
|
4243
|
+
explicitly opts in, preserve the talent's preferred provider/model where
|
|
4244
|
+
possible while still repairing missing fields.
|
|
4236
4245
|
"""
|
|
4237
4246
|
hosting = talent_data.get("hosting", "")
|
|
4238
|
-
if hosting in ("self", HostingMode.SELF):
|
|
4247
|
+
if hosting in ("self", "remote", HostingMode.SELF, HostingMode.REMOTE):
|
|
4248
|
+
return
|
|
4249
|
+
|
|
4250
|
+
from onemancompany.core.config import normalize_llm_profile_defaults, settings as _settings
|
|
4251
|
+
|
|
4252
|
+
if use_talent_llm_config:
|
|
4253
|
+
label = talent_data.get("talent_id") or talent_data.get("id") or talent_data.get("name") or "talent"
|
|
4254
|
+
normalize_llm_profile_defaults(talent_data, reason=f"talent {label}")
|
|
4239
4255
|
return
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
if
|
|
4245
|
-
talent_data
|
|
4246
|
-
|
|
4256
|
+
|
|
4257
|
+
company_model = _settings.default_llm_model
|
|
4258
|
+
company_provider = _settings.default_api_provider or "openrouter"
|
|
4259
|
+
|
|
4260
|
+
if company_model:
|
|
4261
|
+
if talent_data.get("llm_model") != company_model:
|
|
4262
|
+
logger.info(
|
|
4263
|
+
"[hiring] Overriding talent llm_model '{}' with company default '{}'",
|
|
4264
|
+
talent_data.get("llm_model", ""),
|
|
4265
|
+
company_model,
|
|
4266
|
+
)
|
|
4267
|
+
talent_data["llm_model"] = company_model
|
|
4268
|
+
|
|
4269
|
+
if talent_data.get("api_provider") != company_provider:
|
|
4270
|
+
logger.info(
|
|
4271
|
+
"[hiring] Overriding talent api_provider '{}' with company default '{}'",
|
|
4272
|
+
talent_data.get("api_provider", ""),
|
|
4273
|
+
company_provider,
|
|
4274
|
+
)
|
|
4275
|
+
talent_data["api_provider"] = company_provider
|
|
4276
|
+
|
|
4247
4277
|
if not talent_data.get("auth_method"):
|
|
4248
4278
|
talent_data["auth_method"] = "api_key"
|
|
4249
4279
|
|
|
@@ -4330,6 +4360,7 @@ async def _cleanup_single_hire_failure(
|
|
|
4330
4360
|
async def _do_hire_single(
|
|
4331
4361
|
batch_id: str, candidate_id: str, nickname: str,
|
|
4332
4362
|
candidate: dict, coo_ctx: dict,
|
|
4363
|
+
*, use_talent_llm_config: bool = False,
|
|
4333
4364
|
) -> None:
|
|
4334
4365
|
"""Background task: execute hire + post-hire notifications."""
|
|
4335
4366
|
from pathlib import Path
|
|
@@ -4378,8 +4409,8 @@ async def _do_hire_single(
|
|
|
4378
4409
|
logger.debug("[hiring] No local profile for talent {}, using candidate data", talent_id)
|
|
4379
4410
|
talent_data = candidate
|
|
4380
4411
|
|
|
4381
|
-
#
|
|
4382
|
-
_fill_talent_defaults(talent_data)
|
|
4412
|
+
# Default to company config unless the hirer explicitly opts in.
|
|
4413
|
+
_fill_talent_defaults(talent_data, use_talent_llm_config=use_talent_llm_config)
|
|
4383
4414
|
|
|
4384
4415
|
# Validate required fields
|
|
4385
4416
|
missing = _check_talent_required_fields(talent_data)
|
|
@@ -4810,8 +4841,11 @@ async def _do_batch_hire(
|
|
|
4810
4841
|
logger.debug("[batch-hire] No local profile for talent {}, using candidate data", talent_id)
|
|
4811
4842
|
talent_data = candidate
|
|
4812
4843
|
|
|
4813
|
-
#
|
|
4814
|
-
_fill_talent_defaults(
|
|
4844
|
+
# Default to company config unless the hirer explicitly opts in.
|
|
4845
|
+
_fill_talent_defaults(
|
|
4846
|
+
talent_data,
|
|
4847
|
+
use_talent_llm_config=bool(sel.get("use_talent_llm_config", False)),
|
|
4848
|
+
)
|
|
4815
4849
|
|
|
4816
4850
|
# Validate required fields
|
|
4817
4851
|
missing = _check_talent_required_fields(talent_data)
|
|
@@ -406,13 +406,11 @@ ENGINEERING_DEPT = "Engineering"
|
|
|
406
406
|
# Default tool permissions by department (set during hiring)
|
|
407
407
|
# Note: read, ls, write, edit are now BASE_TOOLS (always available, no permission needed).
|
|
408
408
|
# Only gated tools need to be listed here.
|
|
409
|
-
DEFAULT_TOOL_PERMISSIONS
|
|
410
|
-
"Engineering": [
|
|
411
|
-
|
|
412
|
-
],
|
|
413
|
-
"
|
|
414
|
-
"Analytics": ["use_tool"],
|
|
415
|
-
"Marketing": ["use_tool"],
|
|
409
|
+
DEFAULT_TOOL_PERMISSIONS = {
|
|
410
|
+
"Engineering": ["bash", "use_tool"],
|
|
411
|
+
"Design": ["bash", "use_tool"],
|
|
412
|
+
"Analytics": ["bash", "use_tool"],
|
|
413
|
+
"Marketing": ["bash", "use_tool"],
|
|
416
414
|
"General": [],
|
|
417
415
|
}
|
|
418
416
|
DEFAULT_TOOL_PERMISSIONS_FALLBACK: list[str] = []
|
|
@@ -655,6 +653,98 @@ def sync_founding_defaults(provider: str, model: str) -> int:
|
|
|
655
653
|
return synced
|
|
656
654
|
|
|
657
655
|
|
|
656
|
+
def resolve_provider_key(provider_name: str, employee_api_key: str = "") -> str:
|
|
657
|
+
"""Resolve an API key from employee override first, then company settings."""
|
|
658
|
+
if employee_api_key:
|
|
659
|
+
return employee_api_key
|
|
660
|
+
provider_name = (provider_name or "").strip()
|
|
661
|
+
if not provider_name:
|
|
662
|
+
return ""
|
|
663
|
+
prov = get_provider(provider_name)
|
|
664
|
+
if prov and prov.env_key:
|
|
665
|
+
return getattr(settings, prov.env_key, "")
|
|
666
|
+
return ""
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def provider_has_credentials(provider_name: str, employee_api_key: str = "") -> bool:
|
|
670
|
+
"""Return whether a provider can be used by this employee/company."""
|
|
671
|
+
return bool(resolve_provider_key(provider_name, employee_api_key))
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def normalize_llm_profile_defaults(profile: dict, *, reason: str = "employee") -> bool:
|
|
675
|
+
"""Normalize company-hosted LLM settings in a profile-like dict.
|
|
676
|
+
|
|
677
|
+
Imported talents can carry provider-specific defaults such as OpenRouter.
|
|
678
|
+
If that provider has no credentials in this company, use the configured
|
|
679
|
+
company default provider/model instead of creating an employee that fails
|
|
680
|
+
during startup.
|
|
681
|
+
"""
|
|
682
|
+
hosting = profile.get("hosting", "")
|
|
683
|
+
if hosting in ("self", "remote"):
|
|
684
|
+
return False
|
|
685
|
+
|
|
686
|
+
changed = False
|
|
687
|
+
default_provider = settings.default_api_provider or PROVIDER_OPENROUTER
|
|
688
|
+
default_model = settings.default_llm_model
|
|
689
|
+
|
|
690
|
+
provider = (profile.get("api_provider") or "").strip()
|
|
691
|
+
employee_key = (profile.get("api_key") or "").strip()
|
|
692
|
+
provider_missing_key = provider and not provider_has_credentials(provider, employee_key)
|
|
693
|
+
default_has_key = provider_has_credentials(default_provider)
|
|
694
|
+
|
|
695
|
+
if not profile.get("api_provider"):
|
|
696
|
+
profile["api_provider"] = default_provider
|
|
697
|
+
provider = default_provider
|
|
698
|
+
changed = True
|
|
699
|
+
logger.info("[llm-config] {} missing api_provider — using company default: {}", reason, default_provider)
|
|
700
|
+
elif provider != default_provider and provider_missing_key and default_has_key:
|
|
701
|
+
logger.info(
|
|
702
|
+
"[llm-config] {} provider '{}' has no credentials — using company default '{}'",
|
|
703
|
+
reason,
|
|
704
|
+
provider,
|
|
705
|
+
default_provider,
|
|
706
|
+
)
|
|
707
|
+
profile["api_provider"] = default_provider
|
|
708
|
+
provider = default_provider
|
|
709
|
+
changed = True
|
|
710
|
+
# Provider-specific model names are not always portable across gateways.
|
|
711
|
+
if default_model and profile.get("llm_model") != default_model:
|
|
712
|
+
profile["llm_model"] = default_model
|
|
713
|
+
changed = True
|
|
714
|
+
|
|
715
|
+
if not profile.get("llm_model") and default_model:
|
|
716
|
+
profile["llm_model"] = default_model
|
|
717
|
+
changed = True
|
|
718
|
+
logger.info("[llm-config] {} missing llm_model — using company default: {}", reason, default_model)
|
|
719
|
+
|
|
720
|
+
if not profile.get("auth_method"):
|
|
721
|
+
profile["auth_method"] = "api_key"
|
|
722
|
+
changed = True
|
|
723
|
+
|
|
724
|
+
return changed
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def sync_company_hosted_llm_defaults() -> int:
|
|
728
|
+
"""Repair existing company-hosted employee profiles with unusable LLM config."""
|
|
729
|
+
synced = 0
|
|
730
|
+
if not EMPLOYEES_DIR.exists():
|
|
731
|
+
return synced
|
|
732
|
+
|
|
733
|
+
for emp_dir in sorted(EMPLOYEES_DIR.iterdir()):
|
|
734
|
+
if not emp_dir.is_dir():
|
|
735
|
+
continue
|
|
736
|
+
profile_path = emp_dir / PROFILE_FILENAME
|
|
737
|
+
if not profile_path.exists():
|
|
738
|
+
continue
|
|
739
|
+
data = yaml.safe_load(read_text_utf(profile_path)) or {}
|
|
740
|
+
if data.get(PF_REMOTE):
|
|
741
|
+
continue
|
|
742
|
+
if normalize_llm_profile_defaults(data, reason=f"employee {emp_dir.name}"):
|
|
743
|
+
write_text_utf(profile_path, yaml.dump(data, default_flow_style=False, allow_unicode=True))
|
|
744
|
+
synced += 1
|
|
745
|
+
return synced
|
|
746
|
+
|
|
747
|
+
|
|
658
748
|
# ---------------------------------------------------------------------------
|
|
659
749
|
# Application config (config.yaml at project root)
|
|
660
750
|
# ---------------------------------------------------------------------------
|
|
@@ -16,17 +16,60 @@ import yaml
|
|
|
16
16
|
from onemancompany.core.config import EMPLOYEES_DIR, PROJECT_YAML_FILENAME, TASK_TREE_FILENAME, read_text_utf
|
|
17
17
|
from onemancompany.core.task_lifecycle import RESOLVED, TaskPhase, NodeType
|
|
18
18
|
|
|
19
|
+
_CLOSED_PROJECT_STATUSES = {"archived", "completed", "failed", "cancelled"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _project_metadata_paths(tree_path: Path) -> list[Path]:
|
|
23
|
+
"""Return possible project/iteration metadata files for a task tree."""
|
|
24
|
+
project_dir = tree_path.parent
|
|
25
|
+
candidates = [
|
|
26
|
+
project_dir / PROJECT_YAML_FILENAME,
|
|
27
|
+
]
|
|
28
|
+
if project_dir.parent.name == "iterations":
|
|
29
|
+
candidates.extend([
|
|
30
|
+
project_dir.parent / f"{project_dir.name}.yaml",
|
|
31
|
+
project_dir.parent.parent / PROJECT_YAML_FILENAME,
|
|
32
|
+
])
|
|
33
|
+
else:
|
|
34
|
+
candidates.extend([
|
|
35
|
+
project_dir.parent / PROJECT_YAML_FILENAME,
|
|
36
|
+
project_dir.parent.parent / PROJECT_YAML_FILENAME,
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
seen: set[Path] = set()
|
|
40
|
+
unique: list[Path] = []
|
|
41
|
+
for candidate in candidates:
|
|
42
|
+
if candidate not in seen:
|
|
43
|
+
seen.add(candidate)
|
|
44
|
+
unique.append(candidate)
|
|
45
|
+
return unique
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _project_statuses(tree_path: Path) -> list[str]:
|
|
49
|
+
"""Load all known status values for the tree's project/iteration."""
|
|
50
|
+
statuses: list[str] = []
|
|
51
|
+
for metadata_path in _project_metadata_paths(tree_path):
|
|
52
|
+
if not metadata_path.exists():
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
doc = yaml.safe_load(read_text_utf(metadata_path)) or {}
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
logger.debug("Skipping unreadable project metadata {}: {}", metadata_path, exc)
|
|
58
|
+
continue
|
|
59
|
+
status = doc.get("status")
|
|
60
|
+
if status:
|
|
61
|
+
statuses.append(str(status))
|
|
62
|
+
return statuses
|
|
63
|
+
|
|
19
64
|
|
|
20
65
|
def _is_project_archived(tree_path: Path) -> bool:
|
|
21
66
|
"""Check if the project containing this tree file is archived."""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
except Exception:
|
|
29
|
-
return False
|
|
67
|
+
return "archived" in _project_statuses(tree_path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_project_closed(tree_path: Path) -> bool:
|
|
71
|
+
"""Check if the project/iteration is already final and should not recover work."""
|
|
72
|
+
return any(status in _CLOSED_PROJECT_STATUSES for status in _project_statuses(tree_path))
|
|
30
73
|
|
|
31
74
|
|
|
32
75
|
# ---------------------------------------------------------------------------
|
|
@@ -59,9 +102,9 @@ def recover_schedule_from_trees(
|
|
|
59
102
|
# 1. Scan all task_tree.yaml files under projects_dir
|
|
60
103
|
if projects_dir.exists():
|
|
61
104
|
for tree_path in projects_dir.rglob(TASK_TREE_FILENAME):
|
|
62
|
-
# Skip
|
|
63
|
-
if
|
|
64
|
-
logger.debug("Skipping
|
|
105
|
+
# Skip closed projects — no need to restore tasks or stale handlers.
|
|
106
|
+
if _is_project_closed(tree_path):
|
|
107
|
+
logger.debug("Skipping closed project tree: {}", tree_path)
|
|
65
108
|
continue
|
|
66
109
|
try:
|
|
67
110
|
tree = get_tree(tree_path)
|
|
@@ -95,6 +95,15 @@ class TaskNode:
|
|
|
95
95
|
# (agent promised action but didn't call tools). Capped at MAX_STALL_RETRIES.
|
|
96
96
|
stall_retry_count: int = 0
|
|
97
97
|
|
|
98
|
+
# Child failure event ids already surfaced to this parent. This makes
|
|
99
|
+
# failure notifications edge-triggered instead of repeatedly firing while a
|
|
100
|
+
# failed child remains in the tree.
|
|
101
|
+
handled_child_failure_ids: list[str] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
# Stable key for system-generated notification nodes so callers can dedupe
|
|
104
|
+
# retries/recovery without relying on prompt text.
|
|
105
|
+
event_key: str = ""
|
|
106
|
+
|
|
98
107
|
# --- Content externalization tracking (not part of equality/repr) ---
|
|
99
108
|
_content_dirty: bool = field(default=False, init=False, repr=False, compare=False)
|
|
100
109
|
_content_loaded: bool = field(default=False, init=False, repr=False, compare=False)
|
|
@@ -215,6 +224,8 @@ class TaskNode:
|
|
|
215
224
|
"hold_started_at": self.hold_started_at,
|
|
216
225
|
"retry_count": self.retry_count,
|
|
217
226
|
"stall_retry_count": self.stall_retry_count,
|
|
227
|
+
"handled_child_failure_ids": list(self.handled_child_failure_ids),
|
|
228
|
+
"event_key": self.event_key,
|
|
218
229
|
"directives_count": len(self.directives),
|
|
219
230
|
}
|
|
220
231
|
|
|
@@ -1300,6 +1300,107 @@ class EmployeeManager:
|
|
|
1300
1300
|
|
|
1301
1301
|
return count
|
|
1302
1302
|
|
|
1303
|
+
def quiesce_project(self, project_id: str, tree_path: str = "", reason: str = "Project completed") -> int:
|
|
1304
|
+
"""Stop stale scheduled/system work for a project that has reached a final state.
|
|
1305
|
+
|
|
1306
|
+
Work results remain in the tree for audit. Only active system nodes
|
|
1307
|
+
(watchdog nudges, reviews, CEO requests, adhoc/system handlers) are
|
|
1308
|
+
cancelled, and all schedule entries for the project are removed so
|
|
1309
|
+
recovery or in-memory dispatch cannot keep old handlers alive.
|
|
1310
|
+
"""
|
|
1311
|
+
from onemancompany.core.task_lifecycle import safe_cancel
|
|
1312
|
+
from onemancompany.core.task_tree import get_tree, save_tree_async
|
|
1313
|
+
|
|
1314
|
+
def _matches(candidate: str, tree_project_id: str = "") -> bool:
|
|
1315
|
+
ids = {p for p in (candidate, tree_project_id) if p}
|
|
1316
|
+
for existing in ids:
|
|
1317
|
+
if (
|
|
1318
|
+
existing == project_id
|
|
1319
|
+
or existing.startswith(f"{project_id}/")
|
|
1320
|
+
or project_id.startswith(f"{existing}/")
|
|
1321
|
+
):
|
|
1322
|
+
return True
|
|
1323
|
+
return False
|
|
1324
|
+
|
|
1325
|
+
touched_tree_paths: set[str] = {tree_path} if tree_path else set()
|
|
1326
|
+
removed_entries = 0
|
|
1327
|
+
|
|
1328
|
+
for emp_id, entries in list(self._schedule.items()):
|
|
1329
|
+
keep: list[ScheduleEntry] = []
|
|
1330
|
+
for entry in entries:
|
|
1331
|
+
remove_entry = False
|
|
1332
|
+
try:
|
|
1333
|
+
tree = get_tree(entry.tree_path)
|
|
1334
|
+
node = tree.get_node(entry.node_id)
|
|
1335
|
+
if node and _matches(node.project_id, tree.project_id):
|
|
1336
|
+
remove_entry = True
|
|
1337
|
+
touched_tree_paths.add(entry.tree_path)
|
|
1338
|
+
except Exception as e:
|
|
1339
|
+
logger.debug("[quiesce_project] Could not inspect scheduled node {}: {}", entry.node_id, e)
|
|
1340
|
+
if remove_entry:
|
|
1341
|
+
removed_entries += 1
|
|
1342
|
+
else:
|
|
1343
|
+
keep.append(entry)
|
|
1344
|
+
self._schedule[emp_id] = keep
|
|
1345
|
+
|
|
1346
|
+
for emp_id, entry in list(self._current_entries.items()):
|
|
1347
|
+
try:
|
|
1348
|
+
tree = get_tree(entry.tree_path)
|
|
1349
|
+
node = tree.get_node(entry.node_id)
|
|
1350
|
+
if not node or not _matches(node.project_id, tree.project_id):
|
|
1351
|
+
continue
|
|
1352
|
+
touched_tree_paths.add(entry.tree_path)
|
|
1353
|
+
if node.node_type in SYSTEM_NODE_TYPES and TaskPhase(node.status) not in TERMINAL:
|
|
1354
|
+
running = self._running_tasks.get(emp_id)
|
|
1355
|
+
if running and not running.done():
|
|
1356
|
+
running.cancel()
|
|
1357
|
+
logger.info(
|
|
1358
|
+
"[quiesce_project] Cancelled running stale system task {} for project {}",
|
|
1359
|
+
entry.node_id, project_id,
|
|
1360
|
+
)
|
|
1361
|
+
except Exception as e:
|
|
1362
|
+
logger.debug("[quiesce_project] Could not inspect running node {}: {}", entry.node_id, e)
|
|
1363
|
+
|
|
1364
|
+
cancelled_nodes = 0
|
|
1365
|
+
for tp in touched_tree_paths:
|
|
1366
|
+
if not tp:
|
|
1367
|
+
continue
|
|
1368
|
+
try:
|
|
1369
|
+
tree = get_tree(tp)
|
|
1370
|
+
except Exception as e:
|
|
1371
|
+
logger.debug("[quiesce_project] Could not load tree {}: {}", tp, e)
|
|
1372
|
+
continue
|
|
1373
|
+
|
|
1374
|
+
dirty = False
|
|
1375
|
+
for node in tree.all_nodes():
|
|
1376
|
+
if not _matches(node.project_id, tree.project_id):
|
|
1377
|
+
continue
|
|
1378
|
+
if node.node_type not in SYSTEM_NODE_TYPES:
|
|
1379
|
+
continue
|
|
1380
|
+
if TaskPhase(node.status) in TERMINAL:
|
|
1381
|
+
continue
|
|
1382
|
+
if safe_cancel(node):
|
|
1383
|
+
node.result = reason
|
|
1384
|
+
node.completed_at = node.completed_at or datetime.now().isoformat()
|
|
1385
|
+
cancelled_nodes += 1
|
|
1386
|
+
dirty = True
|
|
1387
|
+
for cron_name in (f"reply_{node.id}", f"holding_{node.id}"):
|
|
1388
|
+
try:
|
|
1389
|
+
stop_cron(node.employee_id, cron_name)
|
|
1390
|
+
except Exception as exc:
|
|
1391
|
+
logger.debug("[quiesce_project] Could not stop cron {}/{}: {}", node.employee_id, cron_name, exc)
|
|
1392
|
+
self._publish_node_update(node.employee_id, node)
|
|
1393
|
+
if dirty:
|
|
1394
|
+
save_tree_async(tp)
|
|
1395
|
+
|
|
1396
|
+
total = removed_entries + cancelled_nodes
|
|
1397
|
+
if total:
|
|
1398
|
+
logger.info(
|
|
1399
|
+
"[quiesce_project] Quieted project {}: {} schedule entries removed, {} system nodes cancelled",
|
|
1400
|
+
project_id, removed_entries, cancelled_nodes,
|
|
1401
|
+
)
|
|
1402
|
+
return total
|
|
1403
|
+
|
|
1303
1404
|
def abort_employee(self, employee_id: str) -> int:
|
|
1304
1405
|
"""Cancel all tasks for an employee. Returns count cancelled."""
|
|
1305
1406
|
from onemancompany.core.task_tree import get_tree, save_tree_async
|
|
@@ -2621,11 +2722,14 @@ class EmployeeManager:
|
|
|
2621
2722
|
if c.node_type == NodeType.REVIEW
|
|
2622
2723
|
and c.status in (TaskPhase.PENDING.value, TaskPhase.PROCESSING.value)
|
|
2623
2724
|
)
|
|
2624
|
-
# Check
|
|
2625
|
-
#
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2725
|
+
# Check whether the CURRENT completing node is a failed
|
|
2726
|
+
# substantive child. Failure notifications must be edge-triggered
|
|
2727
|
+
# per child id; otherwise completed watchdog/review nodes keep
|
|
2728
|
+
# re-notifying about old failed siblings.
|
|
2729
|
+
current_failed_child = (
|
|
2730
|
+
node.status == TaskPhase.FAILED.value
|
|
2731
|
+
and node.node_type not in SYSTEM_NODE_TYPES
|
|
2732
|
+
and node.parent_id == parent_node.id
|
|
2629
2733
|
)
|
|
2630
2734
|
# Check if the CURRENT completing node was cancelled — resume parent
|
|
2631
2735
|
# so it can reassess (e.g. cancelled CEO_REQUEST should unblock parent).
|
|
@@ -2633,15 +2737,22 @@ class EmployeeManager:
|
|
|
2633
2737
|
# siblings — otherwise WATCHDOG_NUDGE completions re-trigger the cancelled
|
|
2634
2738
|
# branch in an infinite loop.
|
|
2635
2739
|
has_cancelled_child = node.status == TaskPhase.CANCELLED.value
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
for c in failed_children
|
|
2740
|
+
handled_failures = getattr(parent_node, "handled_child_failure_ids", []) or []
|
|
2741
|
+
failure_event_key = f"child_failure:{parent_node.id}:{node.id}"
|
|
2742
|
+
failure_already_handled = node.id in handled_failures
|
|
2743
|
+
if current_failed_child and failure_already_handled:
|
|
2744
|
+
logger.debug(
|
|
2745
|
+
"[ON_CHILD_COMPLETE] child {} failure already handled for parent {} — skipping duplicate notification",
|
|
2746
|
+
node.id, parent_node.id,
|
|
2644
2747
|
)
|
|
2748
|
+
|
|
2749
|
+
if (
|
|
2750
|
+
current_failed_child
|
|
2751
|
+
and not failure_already_handled
|
|
2752
|
+
and parent_node.status in (TaskPhase.HOLDING.value, TaskPhase.PROCESSING.value)
|
|
2753
|
+
):
|
|
2754
|
+
parent_node.handled_child_failure_ids = [*handled_failures, node.id]
|
|
2755
|
+
failure_summary = f"[{node.employee_id}] {node.description_preview}: {node.result or 'no details'}"
|
|
2645
2756
|
resume_desc = (
|
|
2646
2757
|
f"[Child Task Failure] The following child tasks have failed:\n\n"
|
|
2647
2758
|
f"{failure_summary}\n\n"
|
|
@@ -2668,19 +2779,39 @@ class EmployeeManager:
|
|
|
2668
2779
|
logger.debug("[TASK LIFECYCLE] parent={} → PROCESSING (child failed, resuming)", parent_node.id)
|
|
2669
2780
|
else:
|
|
2670
2781
|
logger.debug("[TASK LIFECYCLE] parent={} already PROCESSING (child failed, re-dispatching)", parent_node.id)
|
|
2671
|
-
# Inject failure context into
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2782
|
+
# Inject failure context into a short-lived system node. The
|
|
2783
|
+
# event_key protects against duplicate creation if recovery
|
|
2784
|
+
# or a concurrent callback sees the same failure event.
|
|
2785
|
+
notify_node = next(
|
|
2786
|
+
(
|
|
2787
|
+
c for c in children
|
|
2788
|
+
if c.node_type == NodeType.WATCHDOG_NUDGE
|
|
2789
|
+
and getattr(c, "event_key", "") == failure_event_key
|
|
2790
|
+
and TaskPhase(c.status) not in TERMINAL
|
|
2791
|
+
),
|
|
2792
|
+
None,
|
|
2677
2793
|
)
|
|
2678
|
-
notify_node
|
|
2679
|
-
|
|
2680
|
-
|
|
2794
|
+
if notify_node:
|
|
2795
|
+
logger.debug(
|
|
2796
|
+
"[ON_CHILD_COMPLETE] failure notification {} already exists for event {}",
|
|
2797
|
+
notify_node.id, failure_event_key,
|
|
2798
|
+
)
|
|
2799
|
+
else:
|
|
2800
|
+
notify_node = tree.add_child(
|
|
2801
|
+
parent_id=parent_node.id,
|
|
2802
|
+
employee_id=parent_node.employee_id,
|
|
2803
|
+
description=resume_desc,
|
|
2804
|
+
acceptance_criteria=[],
|
|
2805
|
+
timeout_seconds=180,
|
|
2806
|
+
)
|
|
2807
|
+
notify_node.node_type = NodeType.WATCHDOG_NUDGE
|
|
2808
|
+
notify_node.event_key = failure_event_key
|
|
2809
|
+
notify_node.project_id = project_id
|
|
2810
|
+
notify_node.project_dir = parent_node.project_dir or str(Path(entry.tree_path).parent)
|
|
2681
2811
|
save_tree_async(entry.tree_path)
|
|
2682
|
-
|
|
2683
|
-
|
|
2812
|
+
if notify_node.status == TaskPhase.PENDING.value:
|
|
2813
|
+
self.schedule_node(parent_node.employee_id, notify_node.id, entry.tree_path)
|
|
2814
|
+
self._schedule_next(parent_node.employee_id)
|
|
2684
2815
|
|
|
2685
2816
|
elif has_cancelled_child and parent_node.status in (TaskPhase.HOLDING.value, TaskPhase.PROCESSING.value):
|
|
2686
2817
|
cancelled_children = [
|
|
@@ -3204,6 +3335,9 @@ class EmployeeManager:
|
|
|
3204
3335
|
await _store.save_project_status(project_id, ITER_STATUS_FAILED)
|
|
3205
3336
|
else:
|
|
3206
3337
|
complete_project(project_id, label)
|
|
3338
|
+
tree_path = str(Path(node.project_dir) / TASK_TREE_FILENAME) if node.project_dir else ""
|
|
3339
|
+
quiet_reason = "Superseded: project failed" if agent_error else "Superseded: project completed"
|
|
3340
|
+
self.quiesce_project(project_id, tree_path=tree_path, reason=quiet_reason)
|
|
3207
3341
|
|
|
3208
3342
|
# --- Resource cleanup: evict tree cache, task logs, Claude sessions ---
|
|
3209
3343
|
self._release_project_resources(employee_id, node, project_id)
|
|
@@ -489,7 +489,15 @@ async def lifespan(app: FastAPI):
|
|
|
489
489
|
logger.warning("Talent Market connection failed (configure in Settings): {}", e)
|
|
490
490
|
|
|
491
491
|
from onemancompany.core.vessel_config import load_vessel_config
|
|
492
|
-
from onemancompany.core.config import
|
|
492
|
+
from onemancompany.core.config import (
|
|
493
|
+
EMPLOYEES_DIR as _EMPLOYEES_DIR,
|
|
494
|
+
employee_configs as _emp_cfgs,
|
|
495
|
+
sync_company_hosted_llm_defaults,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
_synced_llm_profiles = sync_company_hosted_llm_defaults()
|
|
499
|
+
if _synced_llm_profiles:
|
|
500
|
+
logger.info("[startup] Repaired {} employee LLM profile(s) to use available company defaults", _synced_llm_profiles)
|
|
493
501
|
|
|
494
502
|
# Founding employees — hosting-aware registration
|
|
495
503
|
from onemancompany.core.vessel import register_founding_employee
|